Compare commits
15 Commits
ea3577a45e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95272ca4c5 | ||
|
|
56fe8d7c88 | ||
|
|
72f4bdd88a | ||
|
|
4fb1a4553e | ||
|
|
feea126c5e | ||
|
|
90a74967e0 | ||
|
|
3760571d84 | ||
|
|
9a7f8b47a3 | ||
|
|
ed03494edf | ||
|
|
2fd4019103 | ||
|
|
7c95e4fd4e | ||
|
|
b6fadf6504 | ||
|
|
ea457aecee | ||
|
|
c173543aa3 | ||
|
|
cf438f1a2d |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -23,3 +23,9 @@ config.toml
|
||||
# Debug log output from `-debug` runs
|
||||
veola-debug.log
|
||||
*.log
|
||||
|
||||
# dev screenshot + throwaway preview artifacts
|
||||
.shot_*
|
||||
.shots_*
|
||||
config_shot.toml
|
||||
veola_shot.db*
|
||||
|
||||
10
Makefile
10
Makefile
@@ -7,9 +7,9 @@
|
||||
#
|
||||
# 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
|
||||
TAILWIND_VERSION := v4.3.1
|
||||
HTMX_VERSION := 2.0.9
|
||||
CHARTJS_VERSION := 4.5.1
|
||||
TEMPL_VERSION := v0.3.1020
|
||||
|
||||
TAILWIND_BIN := bin/tailwindcss
|
||||
@@ -33,8 +33,10 @@ $(TAILWIND_BIN):
|
||||
|
||||
# Compile Tailwind utilities (scanned from the .templ sources) into
|
||||
# static/css/tailwind.css. The hand-written component layer is app.css.
|
||||
# Tailwind v4 uses CSS-first config — content sources and theme live in
|
||||
# static/css/input.css via @source and @theme directives.
|
||||
css: $(TAILWIND_BIN)
|
||||
$(TAILWIND_BIN) -c tailwind.config.js -i static/css/input.css -o static/css/tailwind.css --minify
|
||||
$(TAILWIND_BIN) -i static/css/input.css -o static/css/tailwind.css --minify
|
||||
|
||||
# Regenerate templ Go from the .templ sources.
|
||||
generate:
|
||||
|
||||
21
README.md
21
README.md
@@ -17,7 +17,7 @@ See [`veola-spec.md`](veola-spec.md) for the full specification.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Go 1.22+ (developed against 1.25)
|
||||
- Go 1.26+ (developed against 1.26.3)
|
||||
- `make`, `curl`, and a POSIX shell (for the Makefile)
|
||||
- A reachable [ntfy](https://ntfy.sh) instance (self-hosted or ntfy.sh)
|
||||
- An [eBay developer](https://developer.ebay.com) keyset (App ID + Cert ID) — for eBay marketplaces
|
||||
@@ -28,9 +28,9 @@ See [`veola-spec.md`](veola-spec.md) for the full specification.
|
||||
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
|
||||
TAILWIND_VERSION := v4.3.0
|
||||
HTMX_VERSION := 2.0.9
|
||||
CHARTJS_VERSION := 4.5.1
|
||||
TEMPL_VERSION := v0.3.1020
|
||||
```
|
||||
|
||||
@@ -49,17 +49,17 @@ 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):
|
||||
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/v4.3.0):
|
||||
|
||||
```sh
|
||||
# macOS Apple Silicon
|
||||
make css TAILWIND_URL=https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.17/tailwindcss-macos-arm64
|
||||
make css TAILWIND_URL=https://github.com/tailwindlabs/tailwindcss/releases/download/v4.3.0/tailwindcss-macos-arm64
|
||||
|
||||
# macOS Intel
|
||||
make css TAILWIND_URL=https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.17/tailwindcss-macos-x64
|
||||
make css TAILWIND_URL=https://github.com/tailwindlabs/tailwindcss/releases/download/v4.3.0/tailwindcss-macos-x64
|
||||
|
||||
# linux-arm64
|
||||
make css TAILWIND_URL=https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.17/tailwindcss-linux-arm64
|
||||
make css TAILWIND_URL=https://github.com/tailwindlabs/tailwindcss/releases/download/v4.3.0/tailwindcss-linux-arm64
|
||||
```
|
||||
|
||||
Or drop a binary at `./bin/tailwindcss` yourself and the Makefile will use it.
|
||||
@@ -156,8 +156,7 @@ internal/
|
||||
handlers/ HTTP handlers
|
||||
templates/ templ components
|
||||
static/ compiled Tailwind + app.css, vendored htmx/Chart.js, JS
|
||||
tailwind.config.js Tailwind content globs
|
||||
Makefile build targets
|
||||
Makefile build targets (Tailwind v4 config lives in static/css/input.css)
|
||||
```
|
||||
|
||||
## Test
|
||||
@@ -188,7 +187,7 @@ make tools
|
||||
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.
|
||||
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 v4 is CSS-first — content globs and theme tokens live in `static/css/input.css` via `@source` and `@theme`, not in a JS config.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -13,6 +13,25 @@ secure_cookies = true
|
||||
session_secret = "change-this-to-a-random-32-byte-string-aaaa"
|
||||
encryption_key = "change-this-to-a-different-random-32-byte-string-bb"
|
||||
|
||||
# Identity. mode = "local" (default) uses Veola's own password login. mode =
|
||||
# "forward" additionally trusts identity headers set by a reverse proxy doing
|
||||
# forward-auth (Traefik in front of Authentik); local password login stays
|
||||
# available as a break-glass fallback. Users are matched/created by EMAIL.
|
||||
#
|
||||
# SECURITY: forward-auth headers are honored ONLY when the direct connection
|
||||
# comes from a CIDR in trusted_proxies. Without this, anyone who can reach the
|
||||
# port could spoof the headers. Set it to your proxy's address(es). Veola
|
||||
# refuses to start in forward mode with an empty trusted_proxies list.
|
||||
[auth]
|
||||
mode = "local"
|
||||
# trusted_proxies = ["127.0.0.1/32"] # required when mode = "forward"
|
||||
# admin_group = "veola-admins" # IdP group that maps to the admin role
|
||||
# Header names default to Authentik's; override only if your proxy differs.
|
||||
# forward_header_user = "X-Authentik-Username"
|
||||
# forward_header_email = "X-Authentik-Email"
|
||||
# forward_header_name = "X-Authentik-Name"
|
||||
# forward_header_groups = "X-Authentik-Groups"
|
||||
|
||||
[apify]
|
||||
api_key = ""
|
||||
|
||||
@@ -51,10 +70,36 @@ client_secret = ""
|
||||
environment = "production"
|
||||
daily_call_limit = 5000
|
||||
|
||||
# ntfy push notifications. If the server has access control
|
||||
# (auth-default-access: deny-all), set username/password to an ntfy user that
|
||||
# has write access to the topics you publish to; Veola authenticates publishes
|
||||
# with HTTP Basic auth. Leave both blank for an open server.
|
||||
[ntfy]
|
||||
base_url = "https://ntfy.yourdomain.com"
|
||||
default_topic = "veola"
|
||||
username = ""
|
||||
password = ""
|
||||
|
||||
# Resend transactional email (https://resend.com) for opt-in deal alerts and
|
||||
# the weekly digest. api_key and from can also be set via /settings. The from
|
||||
# address domain must be verified in your Resend account. Each user enables
|
||||
# their own email + opt-ins on the Settings page.
|
||||
[resend]
|
||||
api_key = ""
|
||||
from = "Veola <veola@yourdomain.com>"
|
||||
|
||||
# Budget visibility. Apify bills per actor run/result, which Veola cannot read
|
||||
# back exactly, so apify_cost_per_call is a USD estimate multiplied by the
|
||||
# recorded run count. monthly_budget_usd > 0 draws a budget bar on the
|
||||
# dashboard (visible to every signed-in user). Both are overridable via
|
||||
# /settings.
|
||||
[budget]
|
||||
apify_cost_per_call = 0.0
|
||||
monthly_budget_usd = 0.0
|
||||
|
||||
[scheduler]
|
||||
# Per-item poll cadence is set on the add-item form (new items default to a
|
||||
# 12-hour refresh to keep Apify spend down). This global value is the fallback
|
||||
# for items left at 0.
|
||||
global_poll_interval_minutes = 60
|
||||
match_confidence_threshold = 0.6
|
||||
|
||||
108
deploy/authentik-forward-auth.md
Normal file
108
deploy/authentik-forward-auth.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Authentik forward-auth (via Traefik)
|
||||
|
||||
Veola can delegate identity to Authentik using Traefik's `forwardAuth`
|
||||
middleware. Veola itself does not speak OIDC; it trusts identity headers that
|
||||
Authentik's proxy outpost sets, and matches/creates a local user by **email**.
|
||||
Local password login stays available as a break-glass fallback.
|
||||
|
||||
## 1. Veola config
|
||||
|
||||
```toml
|
||||
[auth]
|
||||
mode = "forward"
|
||||
trusted_proxies = ["172.18.0.0/16"] # CIDR(s) of the Traefik container/host
|
||||
admin_group = "veola-admins" # Authentik group that grants admin
|
||||
```
|
||||
|
||||
`trusted_proxies` is mandatory in forward mode: forward-auth headers are only
|
||||
honored when the **direct** connection (before `X-Forwarded-For` rewriting)
|
||||
comes from one of these CIDRs. Without it the headers are spoofable. Use the
|
||||
address Traefik connects from (its Docker network range, or `127.0.0.1/32` if
|
||||
co-located).
|
||||
|
||||
Header names default to Authentik's (`X-Authentik-Username`,
|
||||
`X-Authentik-Email`, `X-Authentik-Name`, `X-Authentik-Groups`); override under
|
||||
`[auth]` only if your outpost differs.
|
||||
|
||||
## 2. Authentik
|
||||
|
||||
- Create a **Proxy Provider** in *forward auth (single application)* mode with
|
||||
the external host (e.g. `https://veola.example.com`).
|
||||
- Bind it to an Application, and bind the embedded/standalone outpost.
|
||||
- Create a group (default name `veola-admins`) and add operators who should be
|
||||
Veola admins. Everyone else lands as a regular user. Role is re-synced from
|
||||
the group on every request.
|
||||
|
||||
## 3. Traefik
|
||||
|
||||
```yaml
|
||||
http:
|
||||
middlewares:
|
||||
authentik:
|
||||
forwardAuth:
|
||||
address: "http://authentik-server:9000/outpost.goauthentik.io/auth/traefik"
|
||||
trustForwardHeader: true
|
||||
authResponseHeaders:
|
||||
- X-authentik-username
|
||||
- X-authentik-email
|
||||
- X-authentik-name
|
||||
- X-authentik-groups
|
||||
# Strip client-supplied identity headers on ingress so only the outpost
|
||||
# can set them (runs before `authentik` on the protected router).
|
||||
authentik-strip:
|
||||
headers:
|
||||
customRequestHeaders:
|
||||
X-authentik-username: ""
|
||||
X-authentik-email: ""
|
||||
X-authentik-name: ""
|
||||
X-authentik-groups: ""
|
||||
X-authentik-uid: ""
|
||||
|
||||
routers:
|
||||
veola:
|
||||
rule: "Host(`veola.example.com`)"
|
||||
service: veola
|
||||
middlewares:
|
||||
- authentik-strip
|
||||
- authentik
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
# The outpost's own endpoints (callback, start, sign_out) must go straight
|
||||
# to Authentik, NOT through the forwardAuth-protected `veola` router. This
|
||||
# router has NO auth middleware and points at the authentik service.
|
||||
veola-outpost:
|
||||
rule: "Host(`veola.example.com`) && PathPrefix(`/outpost.goauthentik.io/`)"
|
||||
priority: 100
|
||||
service: authentik
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
services:
|
||||
authentik:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://authentik-server:9000/"
|
||||
```
|
||||
|
||||
**Router priority — important.** Both routers match the Veola host, so the more
|
||||
specific `veola-outpost` must win. Traefik's *default* priority is the rule
|
||||
length, so the catch-all `veola` router (`Host(...)`) gets a priority equal to
|
||||
its length (e.g. `Host(`veola.parodia.dev`)` = 25). Authentik's docs example
|
||||
sets the outpost router to `priority: 15`, which **silently loses** whenever the
|
||||
hostname rule is longer than 15 chars — the catch-all then swallows
|
||||
`/outpost.goauthentik.io/*` and hands it to Veola, which 404s. Symptom: clicking
|
||||
**Sign out** lands on `/outpost.goauthentik.io/sign_out` with a plain
|
||||
`404 page not found`. Fix: give `veola-outpost` a high explicit priority (e.g.
|
||||
`100`) so it always beats the catch-all.
|
||||
|
||||
(Login still works even when the outpost router loses, because the outpost
|
||||
intercepts `callback`/`start` during the forwardAuth `/auth/traefik` round-trip;
|
||||
`sign_out` is the one path that genuinely needs the dedicated router.)
|
||||
|
||||
## Break-glass
|
||||
|
||||
`/login` and `/setup` still work for local password accounts. If Authentik is
|
||||
down, reach Veola directly (bypassing the proxy) from a trusted network and log
|
||||
in with a local admin account. Forward-auth is a no-op for connections that do
|
||||
not originate from `trusted_proxies`.
|
||||
16
go.mod
16
go.mod
@@ -1,26 +1,28 @@
|
||||
module veola
|
||||
|
||||
go 1.25.0
|
||||
go 1.26.0
|
||||
|
||||
toolchain go1.26.3
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
github.com/a-h/templ v0.3.1020
|
||||
github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de
|
||||
github.com/alexedwards/scs/v2 v2.9.0
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-chi/chi/v5 v5.3.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
golang.org/x/crypto v0.51.0
|
||||
modernc.org/sqlite v1.50.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
modernc.org/sqlite v1.52.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // 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.22 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
modernc.org/libc v1.72.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
modernc.org/libc v1.73.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
49
go.sum
49
go.sum
@@ -8,8 +8,8 @@ github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBV
|
||||
github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
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=
|
||||
@@ -18,8 +18,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
|
||||
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
@@ -28,41 +28,40 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
|
||||
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
|
||||
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
|
||||
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
||||
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
||||
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
|
||||
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/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
|
||||
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
|
||||
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
|
||||
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
||||
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
|
||||
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
||||
modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo=
|
||||
modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
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=
|
||||
|
||||
@@ -36,6 +36,9 @@ type Manager struct {
|
||||
Sessions *scs.SessionManager
|
||||
Store *db.Store
|
||||
hmacKey []byte
|
||||
// forward, when non-nil, enables trust of reverse-proxy identity headers.
|
||||
// See forward.go.
|
||||
forward *ForwardAuthConfig
|
||||
}
|
||||
|
||||
func NewManager(sqlDB *sql.DB, store *db.Store, sessionSecret string, secureCookies bool) (*Manager, error) {
|
||||
|
||||
156
internal/auth/forward.go
Normal file
156
internal/auth/forward.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"veola/internal/models"
|
||||
)
|
||||
|
||||
// ForwardAuthConfig configures trust of reverse-proxy identity headers
|
||||
// (Traefik forwardAuth in front of Authentik). It is nil unless forward-auth
|
||||
// is enabled in config.
|
||||
type ForwardAuthConfig struct {
|
||||
HeaderUser string
|
||||
HeaderEmail string
|
||||
HeaderName string
|
||||
HeaderGroups string
|
||||
AdminGroup string
|
||||
trustedNets []*net.IPNet
|
||||
}
|
||||
|
||||
// NewForwardAuthConfig parses the trusted-proxy CIDRs and returns a ready
|
||||
// config. An entry that is a bare IP (no "/") is treated as a /32 or /128.
|
||||
func NewForwardAuthConfig(headerUser, headerEmail, headerName, headerGroups, adminGroup string, trustedProxies []string) (*ForwardAuthConfig, error) {
|
||||
fa := &ForwardAuthConfig{
|
||||
HeaderUser: headerUser,
|
||||
HeaderEmail: headerEmail,
|
||||
HeaderName: headerName,
|
||||
HeaderGroups: headerGroups,
|
||||
AdminGroup: adminGroup,
|
||||
}
|
||||
for _, raw := range trustedProxies {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(raw, "/") {
|
||||
if strings.Contains(raw, ":") {
|
||||
raw += "/128"
|
||||
} else {
|
||||
raw += "/32"
|
||||
}
|
||||
}
|
||||
_, n, err := net.ParseCIDR(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fa.trustedNets = append(fa.trustedNets, n)
|
||||
}
|
||||
return fa, nil
|
||||
}
|
||||
|
||||
// trusts reports whether a direct-peer IP string is within a trusted CIDR.
|
||||
func (fa *ForwardAuthConfig) trusts(ipStr string) bool {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
for _, n := range fa.trustedNets {
|
||||
if n.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SetForwardAuth enables forward-auth header trust on the manager.
|
||||
func (m *Manager) SetForwardAuth(fa *ForwardAuthConfig) { m.forward = fa }
|
||||
|
||||
type directIPCtxKey struct{}
|
||||
|
||||
// CaptureDirectIP records the direct connection's remote IP into the request
|
||||
// context BEFORE middleware.RealIP rewrites RemoteAddr from X-Forwarded-For.
|
||||
// Forward-auth trust is checked against this captured value, not the
|
||||
// proxy-supplied one, so a client cannot claim to be the proxy.
|
||||
func CaptureDirectIP(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), directIPCtxKey{}, host)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func directIP(ctx context.Context) string {
|
||||
s, _ := ctx.Value(directIPCtxKey{}).(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// ForwardAuth, when enabled, trusts identity headers from a configured proxy.
|
||||
// It runs after LoadUser: if the direct peer is a trusted proxy and an email
|
||||
// header is present, it find-or-creates the local user (keyed by email),
|
||||
// re-syncs role from the IdP group, writes the user into the session + context,
|
||||
// and so supersedes any stale local session. When forward-auth is disabled, or
|
||||
// the peer is untrusted, or no headers are present, it is a no-op and local
|
||||
// session auth applies (break-glass login still works).
|
||||
func (m *Manager) ForwardAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fa := m.forward
|
||||
if fa == nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if !fa.trusts(directIP(r.Context())) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
email := strings.ToLower(strings.TrimSpace(r.Header.Get(fa.HeaderEmail)))
|
||||
if email == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.Header.Get(fa.HeaderUser))
|
||||
if username == "" {
|
||||
username = strings.TrimSpace(r.Header.Get(fa.HeaderName))
|
||||
}
|
||||
role := models.RoleUser
|
||||
if fa.hasAdminGroup(r.Header.Get(fa.HeaderGroups)) {
|
||||
role = models.RoleAdmin
|
||||
}
|
||||
u, err := m.Store.UpsertForwardUser(r.Context(), email, username, role)
|
||||
if err != nil || u == nil {
|
||||
slog.Error("forward-auth upsert failed", "email", email, "err", err)
|
||||
http.Error(w, "forward-auth provisioning failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Keep the session in sync so UserID()/CSRFToken() and a later
|
||||
// non-proxied request behave; rotate only when the bound user changes.
|
||||
if m.UserID(r.Context()) != u.ID {
|
||||
if err := m.LogIn(r.Context(), u.ID); err != nil {
|
||||
slog.Error("forward-auth session login failed", "err", err)
|
||||
}
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), ctxKeyUser, u)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// hasAdminGroup reports whether the IdP groups header contains the admin group.
|
||||
// Authentik joins groups with "|"; commas are also accepted.
|
||||
func (fa *ForwardAuthConfig) hasAdminGroup(header string) bool {
|
||||
if fa.AdminGroup == "" || header == "" {
|
||||
return false
|
||||
}
|
||||
for _, g := range strings.FieldsFunc(header, func(r rune) bool { return r == '|' || r == ',' }) {
|
||||
if strings.EqualFold(strings.TrimSpace(g), fa.AdminGroup) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
53
internal/auth/forward_test.go
Normal file
53
internal/auth/forward_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestForwardAuthTrusts(t *testing.T) {
|
||||
fa, err := NewForwardAuthConfig("u", "e", "n", "g", "admins", []string{"127.0.0.1/32", "10.0.0.0/8", "::1"})
|
||||
if err != nil {
|
||||
t.Fatalf("config: %v", err)
|
||||
}
|
||||
cases := map[string]bool{
|
||||
"127.0.0.1": true,
|
||||
"10.5.6.7": true,
|
||||
"::1": true,
|
||||
"192.168.1.1": false,
|
||||
"8.8.8.8": false,
|
||||
"": false,
|
||||
"not-an-ip": false,
|
||||
}
|
||||
for ip, want := range cases {
|
||||
if got := fa.trusts(ip); got != want {
|
||||
t.Errorf("trusts(%q) = %v, want %v", ip, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardAuthBadCIDR(t *testing.T) {
|
||||
if _, err := NewForwardAuthConfig("u", "e", "n", "g", "a", []string{"not-a-cidr/99"}); err == nil {
|
||||
t.Fatal("expected error for malformed CIDR")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasAdminGroup(t *testing.T) {
|
||||
fa := &ForwardAuthConfig{AdminGroup: "veola-admins"}
|
||||
cases := map[string]bool{
|
||||
"veola-admins": true,
|
||||
"users|veola-admins|staff": true, // Authentik pipe-delimited
|
||||
"users,veola-admins": true, // comma-delimited
|
||||
"VEOLA-ADMINS": true, // case-insensitive
|
||||
"users|staff": false,
|
||||
"": false,
|
||||
"veola-admins-extra": false,
|
||||
}
|
||||
for header, want := range cases {
|
||||
if got := fa.hasAdminGroup(header); got != want {
|
||||
t.Errorf("hasAdminGroup(%q) = %v, want %v", header, got, want)
|
||||
}
|
||||
}
|
||||
// Empty admin group never matches.
|
||||
empty := &ForwardAuthConfig{AdminGroup: ""}
|
||||
if empty.hasAdminGroup("anything") {
|
||||
t.Error("empty admin group should not match")
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
@@ -12,12 +13,53 @@ import (
|
||||
type Config struct {
|
||||
Server ServerConfig `toml:"server"`
|
||||
Security SecurityConfig `toml:"security"`
|
||||
Auth AuthConfig `toml:"auth"`
|
||||
Apify ApifyConfig `toml:"apify"`
|
||||
Ebay EbayConfig `toml:"ebay"`
|
||||
Ntfy NtfyConfig `toml:"ntfy"`
|
||||
Resend ResendConfig `toml:"resend"`
|
||||
Budget BudgetConfig `toml:"budget"`
|
||||
Scheduler SchedulerConfig `toml:"scheduler"`
|
||||
}
|
||||
|
||||
// AuthConfig controls how identities are established. Mode "local" (default)
|
||||
// uses Veola's own password login. Mode "forward" trusts identity headers set
|
||||
// by a reverse proxy doing forward-auth (Traefik in front of Authentik); local
|
||||
// password login stays available as a break-glass fallback. The forward-auth
|
||||
// headers are honored ONLY when the direct connection comes from a CIDR in
|
||||
// TrustedProxies — without that gate anyone reaching the port could spoof them.
|
||||
type AuthConfig struct {
|
||||
Mode string `toml:"mode"`
|
||||
ForwardHeaderUser string `toml:"forward_header_user"`
|
||||
ForwardHeaderEmail string `toml:"forward_header_email"`
|
||||
ForwardHeaderName string `toml:"forward_header_name"`
|
||||
ForwardHeaderGroups string `toml:"forward_header_groups"`
|
||||
AdminGroup string `toml:"admin_group"`
|
||||
TrustedProxies []string `toml:"trusted_proxies"`
|
||||
}
|
||||
|
||||
// ForwardAuthEnabled reports whether forward-auth header trust is active.
|
||||
func (c AuthConfig) ForwardAuthEnabled() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(c.Mode), "forward")
|
||||
}
|
||||
|
||||
// ResendConfig holds Resend transactional-email credentials, set only in
|
||||
// config.toml (never via the web UI). From is an RFC-5322 address, e.g.
|
||||
// "Veola <veola@example.com>".
|
||||
type ResendConfig struct {
|
||||
APIKey string `toml:"api_key"`
|
||||
From string `toml:"from"`
|
||||
}
|
||||
|
||||
// BudgetConfig drives the cost estimate shown to every user. CostPerApifyCall
|
||||
// is a USD estimate multiplied by the recorded Apify run count (Apify does not
|
||||
// report exact spend back to Veola). MonthlyBudgetUSD, when > 0, draws a
|
||||
// budget-consumed bar on the dashboard. Both are overridable via /settings.
|
||||
type BudgetConfig struct {
|
||||
CostPerApifyCall float64 `toml:"apify_cost_per_call"`
|
||||
MonthlyBudgetUSD float64 `toml:"monthly_budget_usd"`
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -84,6 +126,11 @@ type ActorConfig struct {
|
||||
type NtfyConfig struct {
|
||||
BaseURL string `toml:"base_url"`
|
||||
DefaultTopic string `toml:"default_topic"`
|
||||
// Username/Password authenticate publishes via HTTP Basic auth, for ntfy
|
||||
// servers with access control (auth-default-access: deny-all). Leave both
|
||||
// empty for an open server.
|
||||
Username string `toml:"username"`
|
||||
Password string `toml:"password"`
|
||||
}
|
||||
|
||||
type SchedulerConfig struct {
|
||||
@@ -144,6 +191,26 @@ func (c *Config) validate() error {
|
||||
if c.Ebay.DailyCallLimit == 0 {
|
||||
c.Ebay.DailyCallLimit = 5000
|
||||
}
|
||||
// Default the forward-auth header names to Authentik's defaults so a
|
||||
// minimal [auth] block (just mode + trusted_proxies) works out of the box.
|
||||
if c.Auth.ForwardHeaderUser == "" {
|
||||
c.Auth.ForwardHeaderUser = "X-Authentik-Username"
|
||||
}
|
||||
if c.Auth.ForwardHeaderEmail == "" {
|
||||
c.Auth.ForwardHeaderEmail = "X-Authentik-Email"
|
||||
}
|
||||
if c.Auth.ForwardHeaderName == "" {
|
||||
c.Auth.ForwardHeaderName = "X-Authentik-Name"
|
||||
}
|
||||
if c.Auth.ForwardHeaderGroups == "" {
|
||||
c.Auth.ForwardHeaderGroups = "X-Authentik-Groups"
|
||||
}
|
||||
if c.Auth.AdminGroup == "" {
|
||||
c.Auth.AdminGroup = "veola-admins"
|
||||
}
|
||||
if c.Auth.ForwardAuthEnabled() && len(c.Auth.TrustedProxies) == 0 {
|
||||
return errors.New("auth.mode is \"forward\" but auth.trusted_proxies is empty: forward-auth headers would be spoofable. Set the CIDR(s) of your reverse proxy (e.g. [\"127.0.0.1/32\"])")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,51 @@ func Open(path string) (*sql.DB, error) {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
// Per-user item ownership. On a migrated DB the column is added without a
|
||||
// REFERENCES clause (ALTER TABLE ADD COLUMN can't carry one cleanly), then
|
||||
// orphaned items are assigned to the first admin (or, failing that, the
|
||||
// first user) so the operator keeps their existing watchlist. Fresh DBs get
|
||||
// the FK from schema.sql and have no rows to backfill.
|
||||
if err := addColumnIfMissing(conn, "items", "user_id", "INTEGER"); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if _, err := conn.Exec(`
|
||||
UPDATE items SET user_id = (
|
||||
SELECT id FROM users
|
||||
ORDER BY (role = 'admin') DESC, id ASC
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE user_id IS NULL AND EXISTS (SELECT 1 FROM users)
|
||||
`); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("backfill item ownership: %w", err)
|
||||
}
|
||||
if _, err := conn.Exec(`CREATE INDEX IF NOT EXISTS idx_items_user ON items(user_id)`); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("create items user index: %w", err)
|
||||
}
|
||||
// Multi-user / email columns (Authentik forward-auth + Resend mail).
|
||||
for _, c := range []struct{ col, typ string }{
|
||||
{"email", "TEXT"},
|
||||
{"auth_source", "TEXT NOT NULL DEFAULT 'local'"},
|
||||
{"email_deal_alerts", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"email_weekly_digest", "INTEGER NOT NULL DEFAULT 0"},
|
||||
} {
|
||||
if err := addColumnIfMissing(conn, "users", c.col, c.typ); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Email is the identity key for forward-auth (Authentik) and the Resend
|
||||
// destination. Partial unique index: many local users may have no email
|
||||
// (NULL/'') while any populated address stays unique. Created here, after
|
||||
// the email column exists, so it works on both fresh and migrated DBs.
|
||||
if _, err := conn.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email
|
||||
ON users(email) WHERE email IS NOT NULL AND email != ''`); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("create users email index: %w", err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"veola/internal/crypto"
|
||||
"veola/internal/models"
|
||||
@@ -22,15 +23,27 @@ func newTestStore(t *testing.T) *Store {
|
||||
return NewStore(conn, key)
|
||||
}
|
||||
|
||||
// seedUser creates an owner so item rows satisfy the items.user_id foreign key.
|
||||
func seedUser(t *testing.T, s *Store) int64 {
|
||||
t.Helper()
|
||||
id, err := s.CreateUser(context.Background(), "tester", "!x", models.RoleAdmin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestDedupByItemAndURL(t *testing.T) {
|
||||
if os.Getenv("CI_SKIP_SQLITE") != "" {
|
||||
t.Skip()
|
||||
}
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
uid := seedUser(t, s)
|
||||
|
||||
id, err := s.CreateItem(ctx, &models.Item{
|
||||
Name: "TwinBee", NtfyTopic: "veola", Active: true,
|
||||
UserID: uid,
|
||||
Name: "TwinBee", NtfyTopic: "veola", Active: true,
|
||||
PollIntervalMinutes: 60, NtfyPriority: "default",
|
||||
})
|
||||
if err != nil {
|
||||
@@ -61,7 +74,7 @@ func TestDedupByItemAndURL(t *testing.T) {
|
||||
}
|
||||
|
||||
// Different item, same URL should not collide.
|
||||
id2, _ := s.CreateItem(ctx, &models.Item{Name: "Other", NtfyTopic: "veola", PollIntervalMinutes: 60, NtfyPriority: "default"})
|
||||
id2, _ := s.CreateItem(ctx, &models.Item{UserID: uid, Name: "Other", NtfyTopic: "veola", PollIntervalMinutes: 60, NtfyPriority: "default"})
|
||||
other, _ := s.ResultExists(ctx, id2, "https://example.com/listing/1")
|
||||
if other {
|
||||
t.Error("dedup should be scoped to item_id")
|
||||
@@ -74,10 +87,59 @@ func TestDedupByItemAndURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardMoneySavedWindow(t *testing.T) {
|
||||
if os.Getenv("CI_SKIP_SQLITE") != "" {
|
||||
t.Skip()
|
||||
}
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
uid := seedUser(t, s)
|
||||
|
||||
bp := 100.0
|
||||
id, err := s.CreateItem(ctx, &models.Item{
|
||||
UserID: uid,
|
||||
Name: "Windowed", NtfyTopic: "veola", Active: true,
|
||||
PollIntervalMinutes: 60, NtfyPriority: "default",
|
||||
BestPrice: &bp,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
// A stale point well outside the 30-day window at a much higher price. If
|
||||
// the average were computed over all history this would inflate "saved".
|
||||
if err := s.InsertPricePoint(ctx, &models.PricePoint{
|
||||
ItemID: id, Price: 500, PolledAt: now.AddDate(0, 0, -60),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A recent point inside the window. Best price (100) is below it, so the
|
||||
// only legitimate saving is 120 - 100 = 20.
|
||||
if err := s.InsertPricePoint(ctx, &models.PricePoint{
|
||||
ItemID: id, Price: 120, PolledAt: now.AddDate(0, 0, -5),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stats, err := s.GetDashboardStats(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.SavedItemCount != 1 {
|
||||
t.Fatalf("expected 1 saved item, got %d", stats.SavedItemCount)
|
||||
}
|
||||
if got := stats.MoneySaved; got < 19.99 || got > 20.01 {
|
||||
t.Errorf("expected money saved ~20 (recent avg only), got %.2f", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCJKRoundTrip(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
uid := seedUser(t, s)
|
||||
id, err := s.CreateItem(ctx, &models.Item{
|
||||
UserID: uid,
|
||||
Name: "ツインビー",
|
||||
SearchQuery: "ツインビー グラディウス パロディウス",
|
||||
NtfyTopic: "veola",
|
||||
|
||||
@@ -94,6 +94,28 @@ func (s *Store) UserCount(ctx context.Context) (int, error) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
const userSelect = `SELECT id, username, password_hash, role, email, auth_source,
|
||||
email_deal_alerts, email_weekly_digest, created_at FROM users`
|
||||
|
||||
func scanUser(r rowScanner) (*models.User, error) {
|
||||
var (
|
||||
u models.User
|
||||
role, authSource string
|
||||
email sql.NullString
|
||||
dealAlerts, digest int
|
||||
)
|
||||
if err := r.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &email, &authSource,
|
||||
&dealAlerts, &digest, &u.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.Role = models.Role(role)
|
||||
u.Email = email.String
|
||||
u.AuthSource = authSource
|
||||
u.EmailDealAlerts = dealAlerts != 0
|
||||
u.EmailWeeklyDigest = digest != 0
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateUser(ctx context.Context, username, hash string, role models.Role) (int64, error) {
|
||||
res, err := s.DB.ExecContext(ctx,
|
||||
`INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)`,
|
||||
@@ -105,66 +127,203 @@ func (s *Store) CreateUser(ctx context.Context, username, hash string, role mode
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByUsername(ctx context.Context, username string) (*models.User, error) {
|
||||
row := s.DB.QueryRowContext(ctx,
|
||||
`SELECT id, username, password_hash, role, created_at FROM users WHERE username = ?`,
|
||||
username)
|
||||
var u models.User
|
||||
var role string
|
||||
if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &u.CreatedAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
u, err := scanUser(s.DB.QueryRowContext(ctx, userSelect+` WHERE username = ?`, username))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
u.Role = models.Role(role)
|
||||
return &u, nil
|
||||
return u, err
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByID(ctx context.Context, id int64) (*models.User, error) {
|
||||
row := s.DB.QueryRowContext(ctx,
|
||||
`SELECT id, username, password_hash, role, created_at FROM users WHERE id = ?`, id)
|
||||
var u models.User
|
||||
var role string
|
||||
if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &u.CreatedAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
u, err := scanUser(s.DB.QueryRowContext(ctx, userSelect+` WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
u.Role = models.Role(role)
|
||||
return &u, nil
|
||||
return u, err
|
||||
}
|
||||
|
||||
// normalizeEmail lowercases and trims an address so identity matching is
|
||||
// case-insensitive. The users.email unique index and the forward-auth match key
|
||||
// are otherwise byte-exact, which would let "Ada@x.com" and "ada@x.com" become
|
||||
// two accounts for one person.
|
||||
func normalizeEmail(email string) string {
|
||||
return strings.ToLower(strings.TrimSpace(email))
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
|
||||
email = normalizeEmail(email)
|
||||
if email == "" {
|
||||
return nil, nil
|
||||
}
|
||||
u, err := scanUser(s.DB.QueryRowContext(ctx, userSelect+` WHERE email = ?`, email))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
|
||||
func (s *Store) ListUsers(ctx context.Context) ([]models.User, error) {
|
||||
rows, err := s.DB.QueryContext(ctx,
|
||||
`SELECT id, username, password_hash, role, created_at FROM users ORDER BY id`)
|
||||
rows, err := s.DB.QueryContext(ctx, userSelect+` ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []models.User
|
||||
for rows.Next() {
|
||||
var u models.User
|
||||
var role string
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &u.CreatedAt); err != nil {
|
||||
u, err := scanUser(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.Role = models.Role(role)
|
||||
out = append(out, u)
|
||||
out = append(out, *u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpsertForwardUser find-or-creates a user keyed by email for forward-auth
|
||||
// (Authentik). On an existing row it re-syncs role + username so the IdP stays
|
||||
// the source of truth. Returns the resolved user. New rows are marked
|
||||
// auth_source='forward' and carry an unusable password hash (forward-auth
|
||||
// users never log in with a local password).
|
||||
func (s *Store) UpsertForwardUser(ctx context.Context, email, username string, role models.Role) (*models.User, error) {
|
||||
email = normalizeEmail(email)
|
||||
if email == "" {
|
||||
return nil, errors.New("forward-auth requires an email")
|
||||
}
|
||||
existing, err := s.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
newName := existing.Username
|
||||
if username != "" && username != existing.Username {
|
||||
// The IdP renamed the user. users.username is UNIQUE, so the
|
||||
// desired name may already belong to a different row; resolve a
|
||||
// free variant rather than letting the UPDATE fail with a 500.
|
||||
newName, err = s.uniqueUsername(ctx, username, existing.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if existing.Role != role || newName != existing.Username {
|
||||
if _, err := s.DB.ExecContext(ctx,
|
||||
`UPDATE users SET role = ?, username = ? WHERE id = ?`,
|
||||
string(role), newName, existing.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing.Role = role
|
||||
existing.Username = newName
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
desired := username
|
||||
if desired == "" {
|
||||
desired = email
|
||||
}
|
||||
// users.username is UNIQUE; an Authentik username can collide with an
|
||||
// existing local/forward row. Pick a free variant so a new identity is
|
||||
// never locked out by a 500 on a username clash.
|
||||
uname, err := s.uniqueUsername(ctx, desired, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := s.DB.ExecContext(ctx,
|
||||
`INSERT INTO users (username, password_hash, role, email, auth_source) VALUES (?, ?, ?, ?, 'forward')`,
|
||||
uname, "!forward-auth", string(role), email)
|
||||
if err != nil {
|
||||
// A concurrent first-login for the same email may have created the row
|
||||
// between GetUserByEmail and this INSERT; the email unique index then
|
||||
// rejects us. Re-resolve and let the race winner stand.
|
||||
if u, getErr := s.GetUserByEmail(ctx, email); getErr == nil && u != nil {
|
||||
return u, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetUserByID(ctx, id)
|
||||
}
|
||||
|
||||
// uniqueUsername returns desired if it is free (or already held by excludeID),
|
||||
// otherwise appends a numeric suffix until a free name is found. Bounded so a
|
||||
// pathological collision set can't loop forever.
|
||||
func (s *Store) uniqueUsername(ctx context.Context, desired string, excludeID int64) (string, error) {
|
||||
candidate := desired
|
||||
for i := 1; i <= 1000; i++ {
|
||||
ex, err := s.GetUserByUsername(ctx, candidate)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if ex == nil || ex.ID == excludeID {
|
||||
return candidate, nil
|
||||
}
|
||||
candidate = fmt.Sprintf("%s-%d", desired, i)
|
||||
}
|
||||
return "", fmt.Errorf("no free username for %q", desired)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateUserPassword(ctx context.Context, id int64, hash string) error {
|
||||
_, err := s.DB.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, hash, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteUser(ctx context.Context, id int64) error {
|
||||
_, err := s.DB.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id)
|
||||
// UpdateUserEmailPrefs sets a user's notification email and per-channel email
|
||||
// opt-ins. Email "" clears the address (and silences email for that user).
|
||||
func (s *Store) UpdateUserEmailPrefs(ctx context.Context, id int64, email string, dealAlerts, weeklyDigest bool) error {
|
||||
_, err := s.DB.ExecContext(ctx,
|
||||
`UPDATE users SET email = ?, email_deal_alerts = ?, email_weekly_digest = ? WHERE id = ?`,
|
||||
nullStr(email), boolToInt(dealAlerts), boolToInt(weeklyDigest), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// UsersWithDealAlerts returns users who opted into deal-alert email and have a
|
||||
// destination address. UsersWithWeeklyDigest is the digest equivalent.
|
||||
func (s *Store) UsersWithDealAlerts(ctx context.Context) ([]models.User, error) {
|
||||
return s.usersWhereEmailPref(ctx, "email_deal_alerts")
|
||||
}
|
||||
|
||||
func (s *Store) UsersWithWeeklyDigest(ctx context.Context) ([]models.User, error) {
|
||||
return s.usersWhereEmailPref(ctx, "email_weekly_digest")
|
||||
}
|
||||
|
||||
func (s *Store) usersWhereEmailPref(ctx context.Context, col string) ([]models.User, error) {
|
||||
rows, err := s.DB.QueryContext(ctx,
|
||||
userSelect+` WHERE `+col+` = 1 AND email IS NOT NULL AND email != '' ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []models.User
|
||||
for rows.Next() {
|
||||
u, err := scanUser(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteUser removes a user and all items they own. Owned items are deleted
|
||||
// explicitly (rather than relying on an ON DELETE CASCADE that exists only on
|
||||
// fresh databases) so results, price history, and marketplace rows cascade off
|
||||
// the always-present items foreign keys.
|
||||
func (s *Store) DeleteUser(ctx context.Context, id int64) error {
|
||||
tx, err := s.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM items WHERE user_id = ?`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ============ settings ============
|
||||
|
||||
func (s *Store) GetSetting(ctx context.Context, key string) (string, error) {
|
||||
@@ -267,20 +426,61 @@ func (s *Store) IncrementEbayUsage(ctx context.Context) (int, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ============ apify api usage ============
|
||||
|
||||
// apifyUsageDay buckets Apify runs by UTC calendar day. Unlike eBay (which has
|
||||
// its own Pacific-time quota reset) Apify billing is a soft estimate here, so a
|
||||
// plain UTC day keeps the month math simple.
|
||||
func apifyUsageDay() string {
|
||||
return time.Now().UTC().Format("2006-01-02")
|
||||
}
|
||||
|
||||
// IncrementApifyUsage records one Apify actor run against the current UTC day.
|
||||
func (s *Store) IncrementApifyUsage(ctx context.Context) error {
|
||||
_, err := s.DB.ExecContext(ctx, `
|
||||
INSERT INTO apify_api_usage (usage_date, call_count) VALUES (?, 1)
|
||||
ON CONFLICT(usage_date) DO UPDATE SET call_count = call_count + 1
|
||||
`, apifyUsageDay())
|
||||
return err
|
||||
}
|
||||
|
||||
// ApifyUsageToday returns Apify runs recorded for the current UTC day.
|
||||
func (s *Store) ApifyUsageToday(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
err := s.DB.QueryRowContext(ctx,
|
||||
`SELECT call_count FROM apify_api_usage WHERE usage_date = ?`, apifyUsageDay()).Scan(&n)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// ApifyUsageMonth returns total Apify runs in the current UTC calendar month.
|
||||
func (s *Store) ApifyUsageMonth(ctx context.Context) (int, error) {
|
||||
prefix := time.Now().UTC().Format("2006-01") + "-%"
|
||||
var n sql.NullInt64
|
||||
err := s.DB.QueryRowContext(ctx,
|
||||
`SELECT SUM(call_count) FROM apify_api_usage WHERE usage_date LIKE ?`, prefix).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(n.Int64), nil
|
||||
}
|
||||
|
||||
// ============ items ============
|
||||
|
||||
func (s *Store) CreateItem(ctx context.Context, it *models.Item) (int64, error) {
|
||||
res, err := s.DB.ExecContext(ctx, `
|
||||
INSERT INTO items (
|
||||
name, search_query, url, category, target_price, ntfy_topic, ntfy_priority,
|
||||
user_id, name, search_query, url, category, target_price, ntfy_topic, ntfy_priority,
|
||||
poll_interval_minutes, include_out_of_stock, min_price, exclude_keywords,
|
||||
listing_type, condition, region,
|
||||
actor_active, actor_sold, actor_price_compare, use_price_comparison,
|
||||
active, best_price, best_price_currency, best_price_store, best_price_url, best_price_image_url,
|
||||
best_price_title, last_polled_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
it.Name, s.enc(it.SearchQuery), nullStr(it.URL), nullStr(it.Category),
|
||||
it.UserID, it.Name, s.enc(it.SearchQuery), nullStr(it.URL), nullStr(it.Category),
|
||||
nullFloat(it.TargetPrice), s.enc(it.NtfyTopic), it.NtfyPriority,
|
||||
it.PollIntervalMinutes, boolToInt(it.IncludeOutOfStock),
|
||||
nullFloat(it.MinPrice), nullStr(s.enc(it.ExcludeKeywords)),
|
||||
@@ -433,14 +633,29 @@ func (s *Store) GetItem(ctx context.Context, id int64) (*models.Item, error) {
|
||||
return it, nil
|
||||
}
|
||||
|
||||
// ListItems returns every item regardless of owner. Reserved for system-level
|
||||
// callers (e.g. the scheduler); user-facing handlers must use ListItemsForUser.
|
||||
func (s *Store) ListItems(ctx context.Context) ([]models.Item, error) {
|
||||
return s.listItemsWhere(ctx, itemSelect+` ORDER BY name COLLATE NOCASE`)
|
||||
}
|
||||
|
||||
// ListItemsForUser returns only the items owned by the given user.
|
||||
func (s *Store) ListItemsForUser(ctx context.Context, userID int64) ([]models.Item, error) {
|
||||
return s.listItemsWhere(ctx, itemSelect+` WHERE user_id = ? ORDER BY name COLLATE NOCASE`, userID)
|
||||
}
|
||||
|
||||
// ListActiveItems returns every active item across all owners. The scheduler
|
||||
// polls these on a global cadence; ownership only governs who can see and edit
|
||||
// an item, not whether it is polled.
|
||||
func (s *Store) ListActiveItems(ctx context.Context) ([]models.Item, error) {
|
||||
return s.listItemsWhere(ctx, itemSelect+` WHERE active = 1 ORDER BY id`)
|
||||
}
|
||||
|
||||
// ListActiveItemsForUser returns one user's active items, for per-user digests.
|
||||
func (s *Store) ListActiveItemsForUser(ctx context.Context, userID int64) ([]models.Item, error) {
|
||||
return s.listItemsWhere(ctx, itemSelect+` WHERE active = 1 AND user_id = ? ORDER BY id`, userID)
|
||||
}
|
||||
|
||||
func (s *Store) listItemsWhere(ctx context.Context, q string, args ...any) ([]models.Item, error) {
|
||||
rows, err := s.DB.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
@@ -471,6 +686,25 @@ func (s *Store) listItemsWhere(ctx context.Context, q string, args ...any) ([]mo
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListCategoriesForUser returns the distinct categories used across one user's
|
||||
// items, for the category filter and the add-item datalist.
|
||||
func (s *Store) ListCategoriesForUser(ctx context.Context, userID int64) ([]string, error) {
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT DISTINCT category FROM items WHERE user_id = ? AND category IS NOT NULL AND category != '' ORDER BY category COLLATE NOCASE`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var c string
|
||||
if err := rows.Scan(&c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ListCategories(ctx context.Context) ([]string, error) {
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT DISTINCT category FROM items WHERE category IS NOT NULL AND category != '' ORDER BY category COLLATE NOCASE`)
|
||||
if err != nil {
|
||||
@@ -516,7 +750,7 @@ func (s *Store) UpdateItemPollResult(ctx context.Context, id int64, best *models
|
||||
}
|
||||
|
||||
const itemSelect = `
|
||||
SELECT id, name, search_query, url, category, target_price, ntfy_topic, ntfy_priority,
|
||||
SELECT id, user_id, name, search_query, url, category, target_price, ntfy_topic, ntfy_priority,
|
||||
poll_interval_minutes, include_out_of_stock, min_price, exclude_keywords,
|
||||
listing_type, condition, region,
|
||||
actor_active, actor_sold, actor_price_compare, use_price_comparison,
|
||||
@@ -540,9 +774,10 @@ func scanItem(r rowScanner) (*models.Item, error) {
|
||||
targetPrice, minPrice, bestPrice sql.NullFloat64
|
||||
includeOOS, usePC, active int
|
||||
lastPolledAt sql.NullTime
|
||||
userID sql.NullInt64
|
||||
)
|
||||
if err := r.Scan(
|
||||
&it.ID, &it.Name, &searchQuery, &urlS, &category, &targetPrice, &ntfyTopic, &it.NtfyPriority,
|
||||
&it.ID, &userID, &it.Name, &searchQuery, &urlS, &category, &targetPrice, &ntfyTopic, &it.NtfyPriority,
|
||||
&it.PollIntervalMinutes, &includeOOS, &minPrice, &excludeKw,
|
||||
&listingType, &condition, ®ion,
|
||||
&actorA, &actorS, &actorP, &usePC,
|
||||
@@ -551,6 +786,7 @@ func scanItem(r rowScanner) (*models.Item, error) {
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
it.UserID = userID.Int64
|
||||
it.ExcludeKeywords = excludeKw.String
|
||||
it.MinPrice = ptrFloat(minPrice)
|
||||
it.SearchQuery = searchQuery.String
|
||||
@@ -645,12 +881,19 @@ func (s *Store) BackfillResultEndsAt(ctx context.Context, itemID int64, urlStr s
|
||||
|
||||
type ResultsQuery struct {
|
||||
ItemID int64 // 0 = all items
|
||||
Limit int
|
||||
Offset int
|
||||
// OwnerID, when non-zero, restricts results to items owned by that user.
|
||||
// Combine with ItemID=0 to scope "all of my items".
|
||||
OwnerID int64
|
||||
Limit int
|
||||
Offset int
|
||||
Order string // "price_asc", "price_desc", "found_desc" (default), "found_asc"
|
||||
// ExcludeEnded drops rows whose ends_at is in the past. Fixed-price
|
||||
// listings (ends_at IS NULL) are kept regardless: they don't expire.
|
||||
// Exception: yahoo-auctions-jp is auction-only — NULL ends_at there means
|
||||
// the actor's ending_date didn't parse, and those rows are also dropped.
|
||||
ExcludeEnded bool
|
||||
// OnlyAlerted restricts to rows that triggered a deal alert.
|
||||
OnlyAlerted bool
|
||||
}
|
||||
|
||||
func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Result, error) {
|
||||
@@ -673,10 +916,17 @@ func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Resul
|
||||
conds = append(conds, `item_id = ?`)
|
||||
args = append(args, q.ItemID)
|
||||
}
|
||||
if q.OwnerID != 0 {
|
||||
conds = append(conds, `item_id IN (SELECT id FROM items WHERE user_id = ?)`)
|
||||
args = append(args, q.OwnerID)
|
||||
}
|
||||
if q.ExcludeEnded {
|
||||
conds = append(conds, `(ends_at IS NULL OR ends_at > ?)`)
|
||||
conds = append(conds, `((ends_at IS NULL AND source != 'yahoo-auctions-jp') OR ends_at > ?)`)
|
||||
args = append(args, time.Now().UTC())
|
||||
}
|
||||
if q.OnlyAlerted {
|
||||
conds = append(conds, `alerted = 1`)
|
||||
}
|
||||
where := ""
|
||||
if len(conds) > 0 {
|
||||
where = `WHERE ` + strings.Join(conds, ` AND `)
|
||||
@@ -729,7 +979,7 @@ type EndingSoon struct {
|
||||
// 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) {
|
||||
func (s *Store) NextEndingResult(ctx context.Context, itemID, ownerID 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
|
||||
@@ -740,6 +990,10 @@ func (s *Store) NextEndingResult(ctx context.Context, itemID int64, within time.
|
||||
q += ` AND r.item_id = ?`
|
||||
args = append(args, itemID)
|
||||
}
|
||||
if ownerID != 0 {
|
||||
q += ` AND i.user_id = ?`
|
||||
args = append(args, ownerID)
|
||||
}
|
||||
q += ` ORDER BY r.ends_at ASC LIMIT 1`
|
||||
row := s.DB.QueryRowContext(ctx, q, args...)
|
||||
var (
|
||||
@@ -773,7 +1027,7 @@ func (s *Store) CountResults(ctx context.Context, itemID int64, excludeEnded boo
|
||||
args = append(args, itemID)
|
||||
}
|
||||
if excludeEnded {
|
||||
conds = append(conds, `(ends_at IS NULL OR ends_at > ?)`)
|
||||
conds = append(conds, `((ends_at IS NULL AND source != 'yahoo-auctions-jp') OR ends_at > ?)`)
|
||||
args = append(args, time.Now().UTC())
|
||||
}
|
||||
if len(conds) > 0 {
|
||||
@@ -876,37 +1130,75 @@ type DashboardStats struct {
|
||||
SavedItemCount int
|
||||
}
|
||||
|
||||
// GetDashboardStatsForUser returns dashboard stats scoped to one user's items.
|
||||
func (s *Store) GetDashboardStatsForUser(ctx context.Context, userID int64) (*DashboardStats, error) {
|
||||
return s.dashboardStats(ctx, userID)
|
||||
}
|
||||
|
||||
// GetDashboardStats returns stats across all owners. Reserved for system-level
|
||||
// callers; user-facing views must use GetDashboardStatsForUser.
|
||||
func (s *Store) GetDashboardStats(ctx context.Context) (*DashboardStats, error) {
|
||||
return s.dashboardStats(ctx, 0)
|
||||
}
|
||||
|
||||
// dashboardStats computes the dashboard aggregates, optionally restricted to a
|
||||
// single owner. When ownerID is 0 it spans every item.
|
||||
func (s *Store) dashboardStats(ctx context.Context, ownerID int64) (*DashboardStats, error) {
|
||||
d := &DashboardStats{}
|
||||
queries := map[string]any{
|
||||
`SELECT COUNT(*) FROM items`: &d.TotalItems,
|
||||
`SELECT COUNT(*) FROM items WHERE active = 1`: &d.ActiveItems,
|
||||
`SELECT COUNT(*) FROM results WHERE found_at >= datetime('now', '-1 day')`: &d.ResultsToday,
|
||||
`SELECT COUNT(*) FROM results WHERE alerted = 1 AND found_at >= datetime('now', '-1 day')`: &d.AlertsToday,
|
||||
// itemOwner is an AND-able predicate on the items table; resultOwner is the
|
||||
// equivalent for the results table (scoped via the item it belongs to).
|
||||
itemOwner, resultOwner := "", ""
|
||||
var ia, ra []any
|
||||
if ownerID != 0 {
|
||||
itemOwner = ` AND user_id = ?`
|
||||
ia = []any{ownerID}
|
||||
resultOwner = ` AND item_id IN (SELECT id FROM items WHERE user_id = ?)`
|
||||
ra = []any{ownerID}
|
||||
}
|
||||
for q, dst := range queries {
|
||||
if err := s.DB.QueryRowContext(ctx, q).Scan(dst); err != nil {
|
||||
countQueries := []struct {
|
||||
q string
|
||||
dst *int
|
||||
arg []any
|
||||
}{
|
||||
{`SELECT COUNT(*) FROM items WHERE 1=1` + itemOwner, &d.TotalItems, ia},
|
||||
{`SELECT COUNT(*) FROM items WHERE active = 1` + itemOwner, &d.ActiveItems, ia},
|
||||
{`SELECT COUNT(*) FROM results WHERE found_at >= datetime('now', '-1 day')` + resultOwner, &d.ResultsToday, ra},
|
||||
{`SELECT COUNT(*) FROM results WHERE alerted = 1 AND found_at >= datetime('now', '-1 day')` + resultOwner, &d.AlertsToday, ra},
|
||||
}
|
||||
for _, cq := range countQueries {
|
||||
if err := s.DB.QueryRowContext(ctx, cq.q, cq.arg...).Scan(cq.dst); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := s.DB.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(SUM(best_price), 0), COUNT(*)
|
||||
FROM items WHERE active = 1 AND best_price IS NOT NULL
|
||||
`).Scan(&d.PotentialSpend, &d.PricedItemCount); err != nil {
|
||||
FROM items WHERE active = 1 AND best_price IS NOT NULL`+itemOwner,
|
||||
ia...).Scan(&d.PotentialSpend, &d.PricedItemCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.DB.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM items WHERE active = 1 AND best_price IS NULL
|
||||
`).Scan(&d.UnpricedCount); err != nil {
|
||||
SELECT COUNT(*) FROM items WHERE active = 1 AND best_price IS NULL`+itemOwner,
|
||||
ia...).Scan(&d.UnpricedCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// "Money saved" compares each item's current best price against its recent
|
||||
// market average. The average is windowed to the last 30 days so stale
|
||||
// historical prices don't anchor the baseline; items whose only price
|
||||
// history predates the window simply don't contribute an estimate.
|
||||
rows, err := s.DB.QueryContext(ctx, `
|
||||
SELECT i.best_price, AVG(p.price) AS avg_price
|
||||
FROM items i
|
||||
JOIN price_history p ON p.item_id = i.id
|
||||
WHERE i.active = 1 AND i.best_price IS NOT NULL
|
||||
AND p.polled_at >= datetime('now', '-30 day')`+
|
||||
func() string {
|
||||
if ownerID != 0 {
|
||||
return ` AND i.user_id = ?`
|
||||
}
|
||||
return ``
|
||||
}()+`
|
||||
GROUP BY i.id
|
||||
`)
|
||||
`, ia...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -6,11 +6,28 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
email TEXT,
|
||||
-- auth_source records how the row was provisioned: 'local' (password
|
||||
-- login / setup) or 'forward' (Traefik forward-auth from Authentik). For
|
||||
-- forward rows the role is re-synced from the IdP group on every request.
|
||||
auth_source TEXT NOT NULL DEFAULT 'local',
|
||||
email_deal_alerts INTEGER NOT NULL DEFAULT 0,
|
||||
email_weekly_digest INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
-- The email column and its unique index are created in db.go AFTER the
|
||||
-- addColumnIfMissing migrations, so existing databases (whose users table
|
||||
-- predates the email column) gain the column before the index references it.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
-- user_id is the owning user. Items are private to their owner; the
|
||||
-- scheduler still polls every active item regardless of owner. On existing
|
||||
-- databases the column is added by addColumnIfMissing in db.go (without a
|
||||
-- REFERENCES clause) and backfilled to the first admin, so user deletion
|
||||
-- removes owned items explicitly in Store.DeleteUser rather than relying on
|
||||
-- the cascade below.
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
search_query TEXT,
|
||||
url TEXT,
|
||||
@@ -43,6 +60,10 @@ CREATE TABLE IF NOT EXISTS items (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_items_active ON items(active);
|
||||
-- idx_items_user is created in db.go AFTER addColumnIfMissing adds user_id, so
|
||||
-- it works on databases whose items table predates the column (this CREATE
|
||||
-- INDEX would otherwise fail on them, since IF NOT EXISTS on the table is a
|
||||
-- no-op and the old table lacks user_id).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS item_marketplaces (
|
||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
@@ -92,7 +113,19 @@ INSERT OR IGNORE INTO settings (key, value) VALUES
|
||||
('ntfy_base_url', ''),
|
||||
('ntfy_default_topic', 'veola'),
|
||||
('global_poll_interval_minutes', '60'),
|
||||
('match_confidence_threshold', '0.6');
|
||||
('match_confidence_threshold', '0.6'),
|
||||
('apify_cost_per_call', '0.00'),
|
||||
('monthly_budget_usd', '0.00'),
|
||||
('resend_api_key', '');
|
||||
|
||||
-- apify_api_usage tracks Apify actor runs per UTC day so the operator (and
|
||||
-- every signed-in user) can see consumption and an estimated spend. Apify
|
||||
-- bills per actor run / per result, which Veola cannot read back exactly, so
|
||||
-- this is a call counter multiplied by a configurable per-call cost estimate.
|
||||
CREATE TABLE IF NOT EXISTS apify_api_usage (
|
||||
usage_date TEXT PRIMARY KEY,
|
||||
call_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- ebay_api_usage tracks Browse API calls per day so Veola can surface
|
||||
-- consumption and halt polling before the developer keyset's daily call
|
||||
|
||||
97
internal/email/client.go
Normal file
97
internal/email/client.go
Normal file
@@ -0,0 +1,97 @@
|
||||
// Package email sends transactional mail through Resend (https://resend.com).
|
||||
// Veola uses it for opt-in deal alerts and the weekly digest. Credentials are
|
||||
// resolved at call time (settings table overriding config.toml), mirroring how
|
||||
// the ntfy and Apify clients are built per-send.
|
||||
package email
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const apiURL = "https://api.resend.com/emails"
|
||||
|
||||
// Client is a thin wrapper around the Resend send endpoint.
|
||||
type Client struct {
|
||||
APIKey string
|
||||
From string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
// New returns a Resend client. apiKey and from are typically resolved from
|
||||
// settings (falling back to config.toml) by the caller.
|
||||
func New(apiKey, from string) *Client {
|
||||
return &Client{
|
||||
APIKey: strings.TrimSpace(apiKey),
|
||||
From: strings.TrimSpace(from),
|
||||
HTTP: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Configured reports whether the client has enough to attempt a send.
|
||||
func (c *Client) Configured() bool {
|
||||
return c.APIKey != "" && c.From != ""
|
||||
}
|
||||
|
||||
// Message is a single outbound email. HTML is the rendered body; Text is an
|
||||
// optional plain-text alternative.
|
||||
type Message struct {
|
||||
To string
|
||||
Subject string
|
||||
HTML string
|
||||
Text string
|
||||
}
|
||||
|
||||
type sendRequest struct {
|
||||
From string `json:"from"`
|
||||
To []string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
HTML string `json:"html,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// Send delivers one message. Returns an error if the client is unconfigured or
|
||||
// Resend rejects the request.
|
||||
func (c *Client) Send(ctx context.Context, m Message) error {
|
||||
if c.APIKey == "" {
|
||||
return fmt.Errorf("resend api key not configured")
|
||||
}
|
||||
if c.From == "" {
|
||||
return fmt.Errorf("resend from address not configured")
|
||||
}
|
||||
if strings.TrimSpace(m.To) == "" {
|
||||
return fmt.Errorf("recipient address required")
|
||||
}
|
||||
body, err := json.Marshal(sendRequest{
|
||||
From: c.From,
|
||||
To: []string{m.To},
|
||||
Subject: m.Subject,
|
||||
HTML: m.HTML,
|
||||
Text: m.Text,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resend POST: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return fmt.Errorf("resend returned %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -49,6 +49,14 @@ func (a *App) PostLogin(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) PostLogout(w http.ResponseWriter, r *http.Request) {
|
||||
_ = a.Auth.LogOut(r.Context())
|
||||
// In forward-auth mode, destroying only the local session is pointless: the
|
||||
// next request still carries a valid Authentik proxy session and would be
|
||||
// re-provisioned immediately. Send the user through the Authentik outpost
|
||||
// sign-out so the IdP session is cleared too.
|
||||
if a.Cfg.Auth.ForwardAuthEnabled() {
|
||||
http.Redirect(w, r, "/outpost.goauthentik.io/sign_out", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"veola/internal/db"
|
||||
"veola/templates"
|
||||
@@ -32,16 +31,22 @@ func (a *App) GetDashboardRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) dashboardData(r *http.Request) (templates.DashboardData, error) {
|
||||
stats, err := a.Store.GetDashboardStats(r.Context())
|
||||
uid := a.userID(r)
|
||||
// Fail closed: every store method below treats ownerID 0 as "all owners",
|
||||
// so a userless request must error rather than render cross-tenant data.
|
||||
if uid == 0 {
|
||||
return templates.DashboardData{}, errors.New("dashboard: no authenticated user in context")
|
||||
}
|
||||
stats, err := a.Store.GetDashboardStatsForUser(r.Context(), uid)
|
||||
if err != nil {
|
||||
return templates.DashboardData{}, err
|
||||
}
|
||||
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{Limit: 20, ExcludeEnded: true})
|
||||
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{OwnerID: uid, Limit: 8, ExcludeEnded: true})
|
||||
if err != nil {
|
||||
return templates.DashboardData{}, err
|
||||
}
|
||||
itemNames := map[int64]string{}
|
||||
all, _ := a.Store.ListItems(r.Context())
|
||||
all, _ := a.Store.ListItemsForUser(r.Context(), uid)
|
||||
for _, it := range all {
|
||||
itemNames[it.ID] = it.Name
|
||||
}
|
||||
@@ -55,6 +60,7 @@ func (a *App) dashboardData(r *http.Request) (templates.DashboardData, error) {
|
||||
Currency: r.Currency,
|
||||
Source: r.Source,
|
||||
URL: r.URL,
|
||||
ImageURL: r.ImageURL,
|
||||
FoundAt: r.FoundAt,
|
||||
Alerted: r.Alerted,
|
||||
})
|
||||
@@ -63,45 +69,37 @@ func (a *App) dashboardData(r *http.Request) (templates.DashboardData, error) {
|
||||
if err != nil {
|
||||
return templates.DashboardData{}, err
|
||||
}
|
||||
apifyToday, apifyMonth, costPerCall, monthlyBudget := a.Scheduler.ApifyUsage(r.Context())
|
||||
ebayUsed, ebayLimit := a.Scheduler.EbayUsage(r.Context())
|
||||
return templates.DashboardData{
|
||||
Page: a.page(r, "Dashboard", "dashboard"),
|
||||
Stats: stats,
|
||||
RecentResults: rrs,
|
||||
RecentAlerts: alerts,
|
||||
Page: a.page(r, "Dashboard", "dashboard"),
|
||||
Stats: stats,
|
||||
RecentResults: rrs,
|
||||
RecentAlerts: alerts,
|
||||
ApifyToday: apifyToday,
|
||||
ApifyMonth: apifyMonth,
|
||||
ApifyCostPerCall: costPerCall,
|
||||
MonthlyBudget: monthlyBudget,
|
||||
EbayToday: ebayUsed,
|
||||
EbayLimit: ebayLimit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func alertsRecent(a *App, r *http.Request, itemNames map[int64]string) ([]templates.AlertRow, error) {
|
||||
rows, err := a.Store.DB.QueryContext(r.Context(), `
|
||||
SELECT item_id, price, currency, found_at FROM results
|
||||
WHERE alerted = 1 ORDER BY found_at DESC LIMIT 5
|
||||
`)
|
||||
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{OwnerID: a.userID(r), OnlyAlerted: true, Limit: 5})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []templates.AlertRow
|
||||
for rows.Next() {
|
||||
var (
|
||||
itemID int64
|
||||
price sql.NullFloat64
|
||||
currency string
|
||||
foundAt time.Time
|
||||
)
|
||||
if err := rows.Scan(&itemID, &price, ¤cy, &foundAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var p *float64
|
||||
if price.Valid {
|
||||
v := price.Float64
|
||||
p = &v
|
||||
}
|
||||
out := make([]templates.AlertRow, 0, len(results))
|
||||
for _, res := range results {
|
||||
out = append(out, templates.AlertRow{
|
||||
ItemName: itemNames[itemID],
|
||||
Price: p,
|
||||
Currency: currency,
|
||||
FoundAt: foundAt,
|
||||
ItemName: itemNames[res.ItemID],
|
||||
Title: res.Title,
|
||||
Price: res.Price,
|
||||
Currency: res.Currency,
|
||||
ImageURL: res.ImageURL,
|
||||
FoundAt: res.FoundAt,
|
||||
})
|
||||
}
|
||||
return out, rows.Err()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -49,6 +49,10 @@ func (a *App) Routes() http.Handler {
|
||||
// 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.
|
||||
// CaptureDirectIP must run before RealIP: forward-auth trusts identity
|
||||
// headers only from the real connecting peer, which RealIP would otherwise
|
||||
// overwrite with the proxy-supplied X-Forwarded-For.
|
||||
r.Use(auth.CaptureDirectIP)
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(securityHeaders)
|
||||
@@ -70,6 +74,10 @@ func (a *App) Routes() http.Handler {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(a.Auth.Sessions.LoadAndSave)
|
||||
r.Use(a.Auth.LoadUser)
|
||||
// ForwardAuth runs after LoadUser so a trusted proxy's identity headers
|
||||
// supersede any stale local session. No-op when forward-auth is off or
|
||||
// the peer is untrusted.
|
||||
r.Use(a.Auth.ForwardAuth)
|
||||
r.Use(a.setupGate)
|
||||
|
||||
// Public auth pages.
|
||||
@@ -106,6 +114,8 @@ func (a *App) Routes() http.Handler {
|
||||
r.With(a.Auth.CSRFProtect).Post("/settings/test-ntfy", a.PostTestNtfy)
|
||||
r.With(a.Auth.CSRFProtect).Post("/settings/test-apify", a.PostTestApify)
|
||||
r.With(a.Auth.CSRFProtect).Post("/settings/test-ebay", a.PostTestEbay)
|
||||
r.With(a.Auth.CSRFProtect).Post("/settings/test-resend", a.PostTestResend)
|
||||
r.With(a.Auth.CSRFProtect).Post("/settings/email", a.PostEmailPrefs)
|
||||
r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users", a.PostCreateUser)
|
||||
r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users/{id}/delete", a.PostDeleteUser)
|
||||
r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users/{id}/reset-password", a.PostResetPassword)
|
||||
@@ -180,6 +190,15 @@ func (a *App) page(r *http.Request, title, active string) templates.Page {
|
||||
}
|
||||
}
|
||||
|
||||
// userID returns the signed-in user's id. Handlers in this group all run under
|
||||
// RequireAuth, so a user is always present; 0 is only returned defensively.
|
||||
func (a *App) userID(r *http.Request) int64 {
|
||||
if u := auth.CurrentUserFromRequest(r); u != nil {
|
||||
return u.ID
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// 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 }
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -113,3 +115,288 @@ func TestLoginPageRenders(t *testing.T) {
|
||||
t.Fatalf("body missing <form>")
|
||||
}
|
||||
}
|
||||
|
||||
// ============ forward-auth (Authentik) integration ============
|
||||
|
||||
// Authentik header names, matching internal/config defaults.
|
||||
const (
|
||||
hdrEmail = "X-Authentik-Email"
|
||||
hdrUsername = "X-Authentik-Username"
|
||||
hdrName = "X-Authentik-Name"
|
||||
hdrGroups = "X-Authentik-Groups"
|
||||
)
|
||||
|
||||
// enableForwardAuth turns on forward-auth trust for the 10.0.0.0/8 range with
|
||||
// admin group "veola-admins", mirroring a Traefik-in-front deployment.
|
||||
func enableForwardAuth(t *testing.T, app *App) {
|
||||
t.Helper()
|
||||
fa, err := auth.NewForwardAuthConfig(hdrUsername, hdrEmail, hdrName, hdrGroups, "veola-admins",
|
||||
[]string{"10.0.0.0/8"})
|
||||
if err != nil {
|
||||
t.Fatalf("NewForwardAuthConfig: %v", err)
|
||||
}
|
||||
app.Auth.SetForwardAuth(fa)
|
||||
}
|
||||
|
||||
// testClient threads cookies across requests so an scs session survives a
|
||||
// GET-then-POST flow, and applies a fixed peer address + identity headers.
|
||||
type testClient struct {
|
||||
h http.Handler
|
||||
remoteAddr string
|
||||
headers map[string]string
|
||||
cookies map[string]*http.Cookie
|
||||
}
|
||||
|
||||
func newClient(h http.Handler, remoteAddr string, headers map[string]string) *testClient {
|
||||
return &testClient{h: h, remoteAddr: remoteAddr, headers: headers, cookies: map[string]*http.Cookie{}}
|
||||
}
|
||||
|
||||
func (c *testClient) do(method, target string, form url.Values) *httptest.ResponseRecorder {
|
||||
var req *http.Request
|
||||
if form != nil {
|
||||
req = httptest.NewRequest(method, target, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
} else {
|
||||
req = httptest.NewRequest(method, target, nil)
|
||||
}
|
||||
if c.remoteAddr != "" {
|
||||
req.RemoteAddr = c.remoteAddr
|
||||
}
|
||||
for k, v := range c.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
for _, ck := range c.cookies {
|
||||
req.AddCookie(ck)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
c.h.ServeHTTP(rec, req)
|
||||
for _, ck := range rec.Result().Cookies() {
|
||||
c.cookies[ck.Name] = ck
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
var csrfRe = regexp.MustCompile(`name="csrf_token" value="([^"]+)"`)
|
||||
|
||||
func extractCSRF(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
m := csrfRe.FindStringSubmatch(body)
|
||||
if m == nil {
|
||||
t.Fatal("no csrf_token field in body")
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
func TestForwardAuthProvisionsAdminFromTrustedProxy(t *testing.T) {
|
||||
app, h := newTestApp(t)
|
||||
enableForwardAuth(t, app)
|
||||
|
||||
c := newClient(h, "10.0.0.5:5555", map[string]string{
|
||||
hdrEmail: "ada@example.com",
|
||||
hdrUsername: "ada",
|
||||
hdrGroups: "users|veola-admins",
|
||||
})
|
||||
rec := c.do(http.MethodGet, "/", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (provisioned dashboard); body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
u, err := app.Store.GetUserByEmail(context.Background(), "ada@example.com")
|
||||
if err != nil || u == nil {
|
||||
t.Fatalf("GetUserByEmail: u=%v err=%v", u, err)
|
||||
}
|
||||
if u.Role != models.RoleAdmin {
|
||||
t.Errorf("role = %q, want admin (from veola-admins group)", u.Role)
|
||||
}
|
||||
if u.AuthSource != "forward" {
|
||||
t.Errorf("auth_source = %q, want forward", u.AuthSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardAuthUntrustedPeerIgnoresSpoofedHeaders(t *testing.T) {
|
||||
app, h := newTestApp(t)
|
||||
enableForwardAuth(t, app)
|
||||
|
||||
// Same headers, but the direct peer is NOT in the trusted range: the
|
||||
// headers must be ignored and no user provisioned.
|
||||
c := newClient(h, "203.0.113.9:5555", map[string]string{
|
||||
hdrEmail: "mallory@example.com",
|
||||
hdrUsername: "mallory",
|
||||
hdrGroups: "veola-admins",
|
||||
})
|
||||
rec := c.do(http.MethodGet, "/", nil)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303 (no trust, setup gate)", rec.Code)
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "/setup" {
|
||||
t.Fatalf("Location = %q, want /setup", loc)
|
||||
}
|
||||
if u, _ := app.Store.GetUserByEmail(context.Background(), "mallory@example.com"); u != nil {
|
||||
t.Fatalf("spoofed header provisioned a user: %+v", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardAuthReSyncsRoleOnEveryRequest(t *testing.T) {
|
||||
app, h := newTestApp(t)
|
||||
enableForwardAuth(t, app)
|
||||
|
||||
admin := newClient(h, "10.0.0.5:5555", map[string]string{
|
||||
hdrEmail: "bea@example.com",
|
||||
hdrGroups: "veola-admins",
|
||||
})
|
||||
if rec := admin.do(http.MethodGet, "/", nil); rec.Code != http.StatusOK {
|
||||
t.Fatalf("first request status = %d, want 200", rec.Code)
|
||||
}
|
||||
u, _ := app.Store.GetUserByEmail(context.Background(), "bea@example.com")
|
||||
if u == nil || u.Role != models.RoleAdmin {
|
||||
t.Fatalf("after admin login, role = %v, want admin", u)
|
||||
}
|
||||
|
||||
// IdP later drops the admin group: the next request must downgrade the role.
|
||||
demoted := newClient(h, "10.0.0.5:5555", map[string]string{
|
||||
hdrEmail: "bea@example.com",
|
||||
hdrGroups: "users",
|
||||
})
|
||||
if rec := demoted.do(http.MethodGet, "/", nil); rec.Code != http.StatusOK {
|
||||
t.Fatalf("second request status = %d, want 200", rec.Code)
|
||||
}
|
||||
u, _ = app.Store.GetUserByEmail(context.Background(), "bea@example.com")
|
||||
if u == nil || u.Role != models.RoleUser {
|
||||
t.Fatalf("after group drop, role = %v, want user", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardAuthEmailPrefsRoundTrip(t *testing.T) {
|
||||
app, h := newTestApp(t)
|
||||
enableForwardAuth(t, app)
|
||||
|
||||
// A forward-auth session authenticates without local login; carry its
|
||||
// cookie + CSRF token into the POST.
|
||||
c := newClient(h, "10.0.0.5:5555", map[string]string{
|
||||
hdrEmail: "cleo@example.com",
|
||||
hdrGroups: "users",
|
||||
})
|
||||
rec := c.do(http.MethodGet, "/settings", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /settings = %d, want 200", rec.Code)
|
||||
}
|
||||
token := extractCSRF(t, rec.Body.String())
|
||||
|
||||
provisioned, _ := app.Store.GetUserByEmail(context.Background(), "cleo@example.com")
|
||||
if provisioned == nil {
|
||||
t.Fatal("forward user not provisioned by GET /settings")
|
||||
}
|
||||
|
||||
// A forward user can toggle opt-ins, but a form that tries to change the
|
||||
// email must be ignored: the address is the Authentik match key, so
|
||||
// changing it here would orphan the row on the next sign-in.
|
||||
form := url.Values{
|
||||
"csrf_token": {token},
|
||||
"email": {"cleo+spoofed@example.com"},
|
||||
"email_deal_alerts": {"1"},
|
||||
"email_weekly_digest": {"1"},
|
||||
}
|
||||
rec = c.do(http.MethodPost, "/settings/email", form)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST /settings/email = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Email is unchanged (still the IdP address), so the row still re-matches.
|
||||
u, err := app.Store.GetUserByEmail(context.Background(), "cleo@example.com")
|
||||
if err != nil || u == nil {
|
||||
t.Fatalf("forward user orphaned: GetUserByEmail(IdP addr) u=%v err=%v", u, err)
|
||||
}
|
||||
if u.ID != provisioned.ID {
|
||||
t.Errorf("re-matched a different row: id %d != %d", u.ID, provisioned.ID)
|
||||
}
|
||||
if u.Email != "cleo@example.com" {
|
||||
t.Errorf("email = %q, want the unchanged IdP address cleo@example.com", u.Email)
|
||||
}
|
||||
if !u.EmailDealAlerts || !u.EmailWeeklyDigest {
|
||||
t.Errorf("opt-ins = (deal:%v digest:%v), want both true", u.EmailDealAlerts, u.EmailWeeklyDigest)
|
||||
}
|
||||
// The user appears in the deal-alert recipient list under their IdP address.
|
||||
recips, err := app.Store.UsersWithDealAlerts(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("UsersWithDealAlerts: %v", err)
|
||||
}
|
||||
if !containsEmail(recips, "cleo@example.com") {
|
||||
t.Errorf("deal-alert recipients %v missing cleo", recips)
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemsArePrivatePerUser(t *testing.T) {
|
||||
app, h := newTestApp(t)
|
||||
enableForwardAuth(t, app)
|
||||
|
||||
// Two distinct forward-auth identities behind the trusted proxy.
|
||||
alice := newClient(h, "10.0.0.5:5555", map[string]string{
|
||||
hdrEmail: "alice@example.com", hdrGroups: "users",
|
||||
})
|
||||
bob := newClient(h, "10.0.0.6:5555", map[string]string{
|
||||
hdrEmail: "bob@example.com", hdrGroups: "users",
|
||||
})
|
||||
|
||||
// Alice creates an item. Pull a CSRF token from the add-item form first.
|
||||
rec := alice.do(http.MethodGet, "/items/new", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /items/new = %d, want 200", rec.Code)
|
||||
}
|
||||
token := extractCSRF(t, rec.Body.String())
|
||||
form := url.Values{
|
||||
"csrf_token": {token},
|
||||
"name": {"Alice TwinBee"},
|
||||
"search_query": {"twinbee"},
|
||||
"marketplace": {"ebay.com"},
|
||||
"poll_interval_minutes": {"720"},
|
||||
"ntfy_priority": {"default"},
|
||||
}
|
||||
rec = alice.do(http.MethodPost, "/items", form)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("POST /items = %d, want 303; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
loc := rec.Header().Get("Location") // /items/{id}/results
|
||||
if !strings.HasPrefix(loc, "/items/") {
|
||||
t.Fatalf("Location = %q, want /items/{id}/results", loc)
|
||||
}
|
||||
itemPath := strings.TrimSuffix(loc, "/results") // /items/{id}
|
||||
|
||||
// Alice sees her item; Bob's list does not.
|
||||
if rec := alice.do(http.MethodGet, "/items", nil); !strings.Contains(rec.Body.String(), "Alice TwinBee") {
|
||||
t.Errorf("Alice's /items missing her own item")
|
||||
}
|
||||
if rec := bob.do(http.MethodGet, "/items", nil); strings.Contains(rec.Body.String(), "Alice TwinBee") {
|
||||
t.Errorf("Bob's /items leaked Alice's item")
|
||||
}
|
||||
|
||||
// Bob cannot reach Alice's item by direct id: results, edit, error, run,
|
||||
// toggle, and delete all 404 rather than acting on someone else's item.
|
||||
if rec := bob.do(http.MethodGet, loc, nil); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("Bob GET %s = %d, want 404", loc, rec.Code)
|
||||
}
|
||||
if rec := bob.do(http.MethodGet, itemPath+"/edit", nil); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("Bob GET %s/edit = %d, want 404", itemPath, rec.Code)
|
||||
}
|
||||
|
||||
// A state-changing attempt needs Bob's own CSRF token (from any rendered
|
||||
// form); it must still 404 on ownership, not act.
|
||||
br := bob.do(http.MethodGet, "/settings", nil)
|
||||
btoken := extractCSRF(t, br.Body.String())
|
||||
del := url.Values{"csrf_token": {btoken}}
|
||||
if rec := bob.do(http.MethodPost, itemPath+"/delete", del); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("Bob POST %s/delete = %d, want 404", itemPath, rec.Code)
|
||||
}
|
||||
|
||||
// Alice's item still exists after Bob's delete attempt.
|
||||
if rec := alice.do(http.MethodGet, loc, nil); rec.Code != http.StatusOK {
|
||||
t.Errorf("Alice GET %s after Bob's delete attempt = %d, want 200", loc, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func containsEmail(us []models.User, email string) bool {
|
||||
for _, u := range us {
|
||||
if u.Email == email {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -16,9 +16,30 @@ import (
|
||||
"veola/templates"
|
||||
)
|
||||
|
||||
// defaultPollIntervalMinutes is the poll cadence pre-selected on the add-item
|
||||
// form: 12 hours, chosen to keep Apify spend low by default.
|
||||
const defaultPollIntervalMinutes = 720
|
||||
|
||||
// ownedItem fetches an item and confirms the current user owns it. It returns
|
||||
// nil when the item is missing or owned by someone else, so callers treat both
|
||||
// cases as a 404 and never leak another user's items.
|
||||
func (a *App) ownedItem(r *http.Request, id int64) (*models.Item, error) {
|
||||
it, err := a.Store.GetItem(r.Context(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Fail closed: a zero uid (no authenticated user) must never match, and an
|
||||
// orphaned item (UserID 0) must never be treated as owned-by-default.
|
||||
uid := a.userID(r)
|
||||
if it == nil || uid == 0 || it.UserID != uid {
|
||||
return nil, nil
|
||||
}
|
||||
return it, nil
|
||||
}
|
||||
|
||||
func (a *App) GetItems(w http.ResponseWriter, r *http.Request) {
|
||||
cat := r.URL.Query().Get("category")
|
||||
all, err := a.Store.ListItems(r.Context())
|
||||
all, err := a.Store.ListItemsForUser(r.Context(), a.userID(r))
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -29,7 +50,7 @@ func (a *App) GetItems(w http.ResponseWriter, r *http.Request) {
|
||||
items = append(items, it)
|
||||
}
|
||||
}
|
||||
cats, _ := a.Store.ListCategories(r.Context())
|
||||
cats, _ := a.Store.ListCategoriesForUser(r.Context(), a.userID(r))
|
||||
|
||||
// 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
|
||||
@@ -50,14 +71,17 @@ func (a *App) GetItems(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) GetNewItem(w http.ResponseWriter, r *http.Request) {
|
||||
cats, _ := a.Store.ListCategories(r.Context())
|
||||
cats, _ := a.Store.ListCategoriesForUser(r.Context(), a.userID(r))
|
||||
render(w, r, templates.ItemForm(templates.ItemFormData{
|
||||
Page: a.page(r, "Add Item", "items"),
|
||||
IsEdit: false,
|
||||
Categories: cats,
|
||||
Item: models.Item{
|
||||
NtfyPriority: "default",
|
||||
PollIntervalMinutes: a.Cfg.Scheduler.GlobalPollIntervalMinutes,
|
||||
NtfyPriority: "default",
|
||||
// Default new items to a 12-hour refresh. Apify runs cost credits
|
||||
// and most watched items do not move hour-to-hour, so a slow
|
||||
// default keeps spend down; the operator can dial it tighter.
|
||||
PollIntervalMinutes: defaultPollIntervalMinutes,
|
||||
Marketplaces: []string{"ebay.com"},
|
||||
ListingType: "all",
|
||||
},
|
||||
@@ -66,12 +90,12 @@ func (a *App) GetNewItem(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) GetEditItem(w http.ResponseWriter, r *http.Request) {
|
||||
id := intParam(r, "id")
|
||||
it, err := a.Store.GetItem(r.Context(), id)
|
||||
it, err := a.ownedItem(r, id)
|
||||
if err != nil || it == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
cats, _ := a.Store.ListCategories(r.Context())
|
||||
cats, _ := a.Store.ListCategoriesForUser(r.Context(), a.userID(r))
|
||||
render(w, r, templates.ItemForm(templates.ItemFormData{
|
||||
Page: a.page(r, "Edit "+it.Name, "items"),
|
||||
IsEdit: true,
|
||||
@@ -334,6 +358,7 @@ func (a *App) PostCreateItem(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, strings.Join(errs, "; "), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
it.UserID = a.userID(r)
|
||||
id, err := a.Store.CreateItem(r.Context(), &it)
|
||||
if err != nil {
|
||||
a.serverError(w, r, err)
|
||||
@@ -357,14 +382,14 @@ func (a *App) PostCreateItem(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) PostUpdateItem(w http.ResponseWriter, r *http.Request) {
|
||||
id := intParam(r, "id")
|
||||
existing, err := a.Store.GetItem(r.Context(), id)
|
||||
existing, err := a.ownedItem(r, id)
|
||||
if err != nil || existing == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
updated, errs := parseItemForm(r)
|
||||
if len(errs) > 0 {
|
||||
cats, _ := a.Store.ListCategories(r.Context())
|
||||
cats, _ := a.Store.ListCategoriesForUser(r.Context(), a.userID(r))
|
||||
updated.ID = id
|
||||
render(w, r, templates.ItemForm(templates.ItemFormData{
|
||||
Page: a.page(r, "Edit "+updated.Name, "items"),
|
||||
@@ -387,7 +412,7 @@ func (a *App) PostUpdateItem(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) PostToggleItem(w http.ResponseWriter, r *http.Request) {
|
||||
id := intParam(r, "id")
|
||||
it, err := a.Store.GetItem(r.Context(), id)
|
||||
it, err := a.ownedItem(r, id)
|
||||
if err != nil || it == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
@@ -404,6 +429,11 @@ func (a *App) PostToggleItem(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) PostDeleteItem(w http.ResponseWriter, r *http.Request) {
|
||||
id := intParam(r, "id")
|
||||
it, err := a.ownedItem(r, id)
|
||||
if err != nil || it == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := a.Store.DeleteItem(r.Context(), id); err != nil {
|
||||
a.serverError(w, r, err)
|
||||
return
|
||||
@@ -414,7 +444,7 @@ func (a *App) PostDeleteItem(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) PostRunItem(w http.ResponseWriter, r *http.Request) {
|
||||
id := intParam(r, "id")
|
||||
it, err := a.Store.GetItem(r.Context(), id)
|
||||
it, err := a.ownedItem(r, id)
|
||||
if err != nil || it == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
@@ -448,7 +478,7 @@ func (a *App) PostRunItem(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) GetItemError(w http.ResponseWriter, r *http.Request) {
|
||||
id := intParam(r, "id")
|
||||
it, err := a.Store.GetItem(r.Context(), id)
|
||||
it, err := a.ownedItem(r, id)
|
||||
if err != nil || it == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
|
||||
@@ -16,7 +16,7 @@ const resultsPerPage = 20
|
||||
|
||||
func (a *App) GetItemResults(w http.ResponseWriter, r *http.Request) {
|
||||
id := intParam(r, "id")
|
||||
it, err := a.Store.GetItem(r.Context(), id)
|
||||
it, err := a.ownedItem(r, id)
|
||||
if err != nil || it == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
@@ -71,7 +71,7 @@ func (a *App) buildItemResultsData(r *http.Request, it *models.Item, page int, o
|
||||
|
||||
// 24h surface for the "ending soon" strip — beyond that, a static
|
||||
// "ends in 4 days" in the per-row cell carries enough signal on its own.
|
||||
endingSoon, _ := a.Store.NextEndingResult(r.Context(), it.ID, 24*time.Hour)
|
||||
endingSoon, _ := a.Store.NextEndingResult(r.Context(), it.ID, 0, 24*time.Hour)
|
||||
|
||||
return templates.ItemResultsData{
|
||||
Page: a.page(r, it.Name, "items"),
|
||||
@@ -105,7 +105,14 @@ func (a *App) GetGlobalResults(w http.ResponseWriter, r *http.Request) {
|
||||
from := strings.TrimSpace(q.Get("from"))
|
||||
to := strings.TrimSpace(q.Get("to"))
|
||||
|
||||
items, err := a.Store.ListItems(r.Context())
|
||||
uid := a.userID(r)
|
||||
// Fail closed: ListResults/NextEndingResult treat OwnerID 0 as "all owners",
|
||||
// so a userless request must not fall through to a cross-tenant view.
|
||||
if uid == 0 {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
items, err := a.Store.ListItemsForUser(r.Context(), uid)
|
||||
if err != nil {
|
||||
a.serverError(w, r, err)
|
||||
return
|
||||
@@ -117,6 +124,7 @@ func (a *App) GetGlobalResults(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{
|
||||
ItemID: itemID,
|
||||
OwnerID: uid,
|
||||
Limit: 200,
|
||||
ExcludeEnded: true,
|
||||
})
|
||||
@@ -145,7 +153,7 @@ func (a *App) GetGlobalResults(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
endingSoon, _ := a.Store.NextEndingResult(r.Context(), itemID, 24*time.Hour)
|
||||
endingSoon, _ := a.Store.NextEndingResult(r.Context(), itemID, uid, 24*time.Hour)
|
||||
|
||||
render(w, r, templates.GlobalResults(templates.GlobalResultsData{
|
||||
Page: a.page(r, "Results", "results"),
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -9,55 +10,24 @@ import (
|
||||
"veola/internal/apify"
|
||||
"veola/internal/auth"
|
||||
"veola/internal/ebay"
|
||||
"veola/internal/email"
|
||||
"veola/internal/models"
|
||||
"veola/internal/ntfy"
|
||||
"veola/templates"
|
||||
)
|
||||
|
||||
// settingsKeys are the non-secret operational settings editable from the
|
||||
// Settings page. API credentials (Apify, eBay, ntfy, Resend) are deliberately
|
||||
// NOT here — they are managed only in config.toml on the server, so the UI
|
||||
// never reads or writes them. The Test buttons verify them.
|
||||
var settingsKeys = []string{
|
||||
"apify_api_key",
|
||||
"ebay_client_id",
|
||||
"ebay_client_secret",
|
||||
"ebay_daily_call_limit",
|
||||
"ntfy_base_url",
|
||||
"ntfy_default_topic",
|
||||
"ntfy_token",
|
||||
"global_poll_interval_minutes",
|
||||
"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
|
||||
"apify_cost_per_call",
|
||||
"monthly_budget_usd",
|
||||
}
|
||||
|
||||
func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
|
||||
@@ -71,17 +41,114 @@ func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
|
||||
users, _ := a.Store.ListUsers(r.Context())
|
||||
cur := auth.CurrentUserFromRequest(r)
|
||||
ebayUsed, ebayLimit := a.Scheduler.EbayUsage(r.Context())
|
||||
apifyToday, apifyMonth, costPerCall, monthlyBudget := a.Scheduler.ApifyUsage(r.Context())
|
||||
return templates.SettingsData{
|
||||
Page: a.page(r, "Settings", "settings"),
|
||||
Values: values,
|
||||
CredentialStatus: a.credentialStatus(values),
|
||||
IsAdmin: cur != nil && cur.Role == models.RoleAdmin,
|
||||
Users: users,
|
||||
EbayUsedToday: ebayUsed,
|
||||
EbayDailyLimit: ebayLimit,
|
||||
ApifyToday: apifyToday,
|
||||
ApifyMonth: apifyMonth,
|
||||
ApifyCostPerCall: costPerCall,
|
||||
MonthlyBudget: monthlyBudget,
|
||||
ForwardAuthMode: a.Cfg.Auth.ForwardAuthEnabled(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PostEmailPrefs lets the signed-in user set their own notification email and
|
||||
// opt into deal-alert / weekly-digest email. Available to every user, not just
|
||||
// admins (each user owns their own delivery prefs).
|
||||
func (a *App) PostEmailPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
cur := auth.CurrentUserFromRequest(r)
|
||||
if cur == nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
email := strings.TrimSpace(r.PostFormValue("email"))
|
||||
// In forward-auth mode, Authentik is the single source of truth for email
|
||||
// addresses — Veola never writes them. For a forward row this also preserves
|
||||
// the IdP match key (changing it would orphan the row on next sign-in); for
|
||||
// a break-glass local row it keeps email management centralized in Authentik.
|
||||
// Only the opt-in toggles are editable here. When forward-auth is off (pure
|
||||
// local deployment, no IdP), local users set their own Resend address.
|
||||
if a.Cfg.Auth.ForwardAuthEnabled() || cur.AuthSource == "forward" {
|
||||
email = cur.Email
|
||||
}
|
||||
dealAlerts := r.PostFormValue("email_deal_alerts") == "1"
|
||||
weeklyDigest := r.PostFormValue("email_weekly_digest") == "1"
|
||||
|
||||
d, err := a.settingsData(r)
|
||||
if err != nil {
|
||||
a.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if email == "" && (dealAlerts || weeklyDigest) {
|
||||
d.EmailError = "Set an email address to receive email notifications."
|
||||
render(w, r, templates.Settings(d))
|
||||
return
|
||||
}
|
||||
if err := a.Store.UpdateUserEmailPrefs(r.Context(), cur.ID, email, dealAlerts, weeklyDigest); err != nil {
|
||||
a.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
// Reflect the saved values immediately on the already-built data (CurrentUser
|
||||
// in d predates the write); no need to reassemble the whole settings page.
|
||||
if d.Page.CurrentUser != nil {
|
||||
d.Page.CurrentUser.Email = email
|
||||
d.Page.CurrentUser.EmailDealAlerts = dealAlerts
|
||||
d.Page.CurrentUser.EmailWeeklyDigest = weeklyDigest
|
||||
}
|
||||
d.EmailMsg = "Email preferences saved."
|
||||
render(w, r, templates.Settings(d))
|
||||
}
|
||||
|
||||
// PostTestResend sends a test email to the admin's own address to verify the
|
||||
// Resend configuration.
|
||||
func (a *App) PostTestResend(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
|
||||
}
|
||||
apiKey := strings.TrimSpace(d.Values["resend_api_key"])
|
||||
if apiKey == "" {
|
||||
apiKey = a.Cfg.Resend.APIKey
|
||||
}
|
||||
from := a.Cfg.Resend.From
|
||||
to := ""
|
||||
if cur != nil {
|
||||
to = cur.Email
|
||||
}
|
||||
if to == "" {
|
||||
d.TestResendOK = "Set your notification email below first, then test."
|
||||
render(w, r, templates.Settings(d))
|
||||
return
|
||||
}
|
||||
client := email.New(apiKey, from)
|
||||
if err := client.Send(r.Context(), email.Message{
|
||||
To: to,
|
||||
Subject: "Veola test email",
|
||||
HTML: "<p>Test email from Veola settings. Resend is configured correctly.</p>",
|
||||
Text: "Test email from Veola settings. Resend is configured correctly.",
|
||||
}); err != nil {
|
||||
d.TestResendOK = "Resend test failed: " + err.Error()
|
||||
} else {
|
||||
d.TestResendOK = "Sent a test email to " + to + "."
|
||||
}
|
||||
render(w, r, templates.Settings(d))
|
||||
}
|
||||
|
||||
func (a *App) GetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
d, err := a.settingsData(r)
|
||||
if err != nil {
|
||||
@@ -103,12 +170,6 @@ func (a *App) PostSettings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
for _, k := range settingsKeys {
|
||||
v := strings.TrimSpace(r.PostFormValue(k))
|
||||
// Secret fields are never rendered back into the form, so a blank
|
||||
// submission is the normal state and means "leave unchanged" — not
|
||||
// "clear". (To clear a stored credential, edit the settings table.)
|
||||
if v == "" && secretSettingsKeys[k] {
|
||||
continue
|
||||
}
|
||||
if err := a.Store.SetSetting(r.Context(), k, v); err != nil {
|
||||
a.serverError(w, r, err)
|
||||
return
|
||||
@@ -175,14 +236,19 @@ func (a *App) PostTestNtfy(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
baseURL := strings.TrimSpace(d.Values["ntfy_base_url"])
|
||||
if baseURL == "" {
|
||||
baseURL = a.Cfg.Ntfy.BaseURL
|
||||
}
|
||||
topic := strings.TrimSpace(d.Values["ntfy_default_topic"])
|
||||
token := strings.TrimSpace(d.Values["ntfy_token"])
|
||||
if topic == "" {
|
||||
topic = a.Cfg.Ntfy.DefaultTopic
|
||||
}
|
||||
if baseURL == "" || topic == "" {
|
||||
d.TestNtfyOK = "Set ntfy base URL and default topic first."
|
||||
render(w, r, templates.Settings(d))
|
||||
return
|
||||
}
|
||||
client := ntfy.NewWithToken(baseURL, token)
|
||||
client := ntfy.NewWithBasicAuth(baseURL, a.Cfg.Ntfy.Username, a.Cfg.Ntfy.Password)
|
||||
if err := client.Send(r.Context(), ntfy.Notification{
|
||||
Topic: topic,
|
||||
Title: "Veola test",
|
||||
@@ -238,6 +304,11 @@ func (a *App) PostTestApify(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
d.TestApifyOK = "Apify test failed: " + err.Error()
|
||||
} else {
|
||||
// The test launches a real, billed actor run; count it toward the
|
||||
// budget view like every other Apify run.
|
||||
if incErr := a.Store.IncrementApifyUsage(r.Context()); incErr != nil {
|
||||
slog.Error("apify usage increment failed", "err", incErr)
|
||||
}
|
||||
d.TestApifyOK = fmt.Sprintf("Apify returned %d item(s).", len(raw))
|
||||
}
|
||||
render(w, r, templates.Settings(d))
|
||||
|
||||
@@ -17,11 +17,20 @@ type User struct {
|
||||
Username string
|
||||
PasswordHash string
|
||||
Role Role
|
||||
CreatedAt time.Time
|
||||
// Email is the address Resend mail is sent to and the identity key used
|
||||
// to match a forward-auth (Authentik) identity to a local row. Optional
|
||||
// for password-login users.
|
||||
Email string
|
||||
// AuthSource is "local" (password) or "forward" (Traefik forward-auth).
|
||||
AuthSource string
|
||||
EmailDealAlerts bool
|
||||
EmailWeeklyDigest bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Item struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
Name string
|
||||
SearchQuery string
|
||||
URL string
|
||||
|
||||
@@ -13,7 +13,12 @@ import (
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
HTTP *http.Client
|
||||
// Username/Password take precedence over Token when set, using HTTP Basic
|
||||
// auth (for ntfy servers that authenticate with user accounts rather than
|
||||
// access tokens).
|
||||
Username string
|
||||
Password string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func New(baseURL string) *Client {
|
||||
@@ -24,13 +29,23 @@ func New(baseURL string) *Client {
|
||||
}
|
||||
|
||||
// NewWithToken returns a ntfy Client with bearer-token auth set. Use this
|
||||
// when the ntfy server requires authentication.
|
||||
// when the ntfy server requires token authentication.
|
||||
func NewWithToken(baseURL, token string) *Client {
|
||||
c := New(baseURL)
|
||||
c.Token = strings.TrimSpace(token)
|
||||
return c
|
||||
}
|
||||
|
||||
// NewWithBasicAuth returns a ntfy Client that authenticates publishes with an
|
||||
// ntfy user account (HTTP Basic auth). Use this for servers configured with
|
||||
// auth-default-access: deny-all and per-user topic ACLs.
|
||||
func NewWithBasicAuth(baseURL, username, password string) *Client {
|
||||
c := New(baseURL)
|
||||
c.Username = strings.TrimSpace(username)
|
||||
c.Password = password
|
||||
return c
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
Topic string
|
||||
Title string
|
||||
@@ -58,7 +73,9 @@ func (c *Client) Send(ctx context.Context, n Notification) error {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
if c.Token != "" {
|
||||
if c.Username != "" {
|
||||
req.SetBasicAuth(c.Username, c.Password)
|
||||
} else if c.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
}
|
||||
if n.Title != "" {
|
||||
@@ -73,17 +90,18 @@ func (c *Client) Send(ctx context.Context, n Notification) error {
|
||||
if n.Click != "" {
|
||||
req.Header.Set("Click", n.Click)
|
||||
}
|
||||
tokenLen := len(c.Token)
|
||||
tokenPrefix := ""
|
||||
if tokenLen >= 4 {
|
||||
tokenPrefix = c.Token[:4]
|
||||
authMode := "none"
|
||||
switch {
|
||||
case c.Username != "":
|
||||
authMode = "basic"
|
||||
case c.Token != "":
|
||||
authMode = "bearer"
|
||||
}
|
||||
slog.Info("ntfy publish",
|
||||
"url", url,
|
||||
"topic", n.Topic,
|
||||
"auth_header_set", c.Token != "",
|
||||
"token_prefix", tokenPrefix,
|
||||
"token_len", tokenLen,
|
||||
"auth", authMode,
|
||||
"user", c.Username,
|
||||
)
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
|
||||
181
internal/scheduler/email.go
Normal file
181
internal/scheduler/email.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"veola/internal/apify"
|
||||
"veola/internal/db"
|
||||
"veola/internal/email"
|
||||
"veola/internal/models"
|
||||
)
|
||||
|
||||
// emailClient builds a Resend client with credentials resolved from settings,
|
||||
// falling back to config.toml. Returns a client even when unconfigured; callers
|
||||
// check Configured() to skip silently.
|
||||
func (s *Scheduler) emailClient(ctx context.Context) *email.Client {
|
||||
apiKey := s.cfg.Resend.APIKey
|
||||
if v, _ := s.store.GetSetting(ctx, "resend_api_key"); v != "" {
|
||||
apiKey = v
|
||||
}
|
||||
return email.New(apiKey, s.cfg.Resend.From)
|
||||
}
|
||||
|
||||
// dealMailer resolves the item owner and Resend client for deal-alert email.
|
||||
// It returns (nil, nil) when email should be skipped (Resend unconfigured, no
|
||||
// owner, owner opted out, or no address) so the caller can resolve once per
|
||||
// poll instead of once per result. Best-effort: errors are logged and swallowed.
|
||||
func (s *Scheduler) dealMailer(ctx context.Context, it models.Item) (*email.Client, *models.User) {
|
||||
client := s.emailClient(ctx)
|
||||
if !client.Configured() || it.UserID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
owner, err := s.store.GetUserByID(ctx, it.UserID)
|
||||
if err != nil {
|
||||
slog.Error("load item owner failed", "item_id", it.ID, "err", err)
|
||||
return nil, nil
|
||||
}
|
||||
if owner == nil || !owner.EmailDealAlerts || owner.Email == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return client, owner
|
||||
}
|
||||
|
||||
// sendDealEmail mails a single deal alert to the resolved owner. The client and
|
||||
// owner come from dealMailer (resolved once per poll), so this does no DB or
|
||||
// settings I/O per result. Items are private, so a deal only ever notifies the
|
||||
// person watching it.
|
||||
func (s *Scheduler) sendDealEmail(ctx context.Context, client *email.Client, owner *models.User, it models.Item, r apify.UnifiedResult) {
|
||||
subject := fmt.Sprintf("Veola deal: %s", it.Name)
|
||||
price := fmt.Sprintf("%s%.2f", currencyPrefix(r.Currency), r.Price)
|
||||
target := ""
|
||||
if it.TargetPrice != nil {
|
||||
target = fmt.Sprintf("%s%.2f", currencyPrefix(r.Currency), *it.TargetPrice)
|
||||
}
|
||||
body := dealEmailHTML(it.Name, r.Title, r.Store, price, target, r.URL)
|
||||
text := dealEmailText(it.Name, r.Title, r.Store, price, target, r.URL)
|
||||
if err := client.Send(ctx, email.Message{
|
||||
To: owner.Email, Subject: subject, HTML: body, Text: text,
|
||||
}); err != nil {
|
||||
slog.Error("deal email send failed", "to", owner.Email, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// RunWeeklyDigest builds and mails the weekly digest to every opted-in user.
|
||||
// Public so it can be triggered manually as well as on the cron schedule.
|
||||
func (s *Scheduler) RunWeeklyDigest(ctx context.Context) {
|
||||
client := s.emailClient(ctx)
|
||||
if !client.Configured() {
|
||||
return
|
||||
}
|
||||
recipients, err := s.store.UsersWithWeeklyDigest(ctx)
|
||||
if err != nil {
|
||||
slog.Error("load digest recipients failed", "err", err)
|
||||
return
|
||||
}
|
||||
if len(recipients) == 0 {
|
||||
return
|
||||
}
|
||||
// Each digest is built from only the recipient's own items and stats, since
|
||||
// items are private. A user watching nothing is skipped rather than mailed
|
||||
// an empty table.
|
||||
sent := 0
|
||||
for _, u := range recipients {
|
||||
items, err := s.store.ListActiveItemsForUser(ctx, u.ID)
|
||||
if err != nil {
|
||||
slog.Error("digest item load failed", "user_id", u.ID, "err", err)
|
||||
continue
|
||||
}
|
||||
if len(items) == 0 {
|
||||
continue
|
||||
}
|
||||
stats, err := s.store.GetDashboardStatsForUser(ctx, u.ID)
|
||||
if err != nil {
|
||||
slog.Error("digest stats load failed", "user_id", u.ID, "err", err)
|
||||
continue
|
||||
}
|
||||
subject := fmt.Sprintf("Veola weekly digest — %d items watched", len(items))
|
||||
if err := client.Send(ctx, email.Message{
|
||||
To: u.Email, Subject: subject, HTML: digestHTML(items, stats), Text: digestText(items, stats),
|
||||
}); err != nil {
|
||||
slog.Error("digest email send failed", "to", u.Email, "err", err)
|
||||
continue
|
||||
}
|
||||
sent++
|
||||
}
|
||||
slog.Info("weekly digest sent", "recipients", sent)
|
||||
}
|
||||
|
||||
func dealEmailHTML(itemName, title, store, price, target, url string) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, `<div style="font-family:sans-serif">`)
|
||||
fmt.Fprintf(&b, `<h2>Veola deal: %s</h2>`, html.EscapeString(itemName))
|
||||
fmt.Fprintf(&b, `<p style="font-size:20px"><strong>%s</strong> at %s</p>`, html.EscapeString(price), html.EscapeString(store))
|
||||
if target != "" {
|
||||
fmt.Fprintf(&b, `<p>Your target: %s</p>`, html.EscapeString(target))
|
||||
}
|
||||
if title != "" {
|
||||
fmt.Fprintf(&b, `<p>%s</p>`, html.EscapeString(title))
|
||||
}
|
||||
if url != "" {
|
||||
fmt.Fprintf(&b, `<p><a href="%s">View listing</a></p>`, html.EscapeString(url))
|
||||
}
|
||||
b.WriteString(`</div>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func dealEmailText(itemName, title, store, price, target, url string) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Veola deal: %s\n\n%s at %s\n", itemName, price, store)
|
||||
if target != "" {
|
||||
fmt.Fprintf(&b, "Your target: %s\n", target)
|
||||
}
|
||||
if title != "" {
|
||||
fmt.Fprintf(&b, "%s\n", title)
|
||||
}
|
||||
if url != "" {
|
||||
fmt.Fprintf(&b, "%s\n", url)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func digestHTML(items []models.Item, stats *db.DashboardStats) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<div style="font-family:sans-serif">`)
|
||||
b.WriteString(`<h2>Veola weekly digest</h2>`)
|
||||
if stats != nil {
|
||||
fmt.Fprintf(&b, `<p>%d active items. Estimated money saved vs 30-day average: $%.2f.</p>`,
|
||||
stats.ActiveItems, stats.MoneySaved)
|
||||
}
|
||||
b.WriteString(`<table style="border-collapse:collapse" cellpadding="6">`)
|
||||
b.WriteString(`<tr><th align="left">Item</th><th align="left">Best price</th><th align="left">Where</th></tr>`)
|
||||
for _, it := range items {
|
||||
best := "—"
|
||||
if it.BestPrice != nil {
|
||||
best = fmt.Sprintf("%s%.2f", currencyPrefix(it.BestPriceCurrency), *it.BestPrice)
|
||||
}
|
||||
fmt.Fprintf(&b, `<tr><td>%s</td><td>%s</td><td>%s</td></tr>`,
|
||||
html.EscapeString(it.Name), html.EscapeString(best), html.EscapeString(it.BestPriceStore))
|
||||
}
|
||||
b.WriteString(`</table></div>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func digestText(items []models.Item, stats *db.DashboardStats) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Veola weekly digest\n\n")
|
||||
if stats != nil {
|
||||
fmt.Fprintf(&b, "%d active items. Estimated money saved vs 30-day average: $%.2f.\n\n", stats.ActiveItems, stats.MoneySaved)
|
||||
}
|
||||
for _, it := range items {
|
||||
best := "no price yet"
|
||||
if it.BestPrice != nil {
|
||||
best = fmt.Sprintf("%s%.2f", currencyPrefix(it.BestPriceCurrency), *it.BestPrice)
|
||||
}
|
||||
fmt.Fprintf(&b, "- %s: %s %s\n", it.Name, best, it.BestPriceStore)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
@@ -64,6 +65,15 @@ func (s *Scheduler) Start(ctx context.Context) error {
|
||||
for _, it := range items {
|
||||
s.register(it)
|
||||
}
|
||||
// Weekly digest: Monday 09:00 server-local. Best-effort inside
|
||||
// RunWeeklyDigest (no-op when Resend is unconfigured or nobody opted in).
|
||||
if _, err := s.cron.AddFunc("0 9 * * 1", func() {
|
||||
ctx, cancel := context.WithTimeout(s.rootCtx, 5*time.Minute)
|
||||
defer cancel()
|
||||
s.RunWeeklyDigest(ctx)
|
||||
}); err != nil {
|
||||
slog.Error("weekly digest schedule failed", "err", err)
|
||||
}
|
||||
s.cron.Start()
|
||||
slog.Info("scheduler started", "items", len(items))
|
||||
return nil
|
||||
@@ -142,7 +152,6 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
|
||||
s.recordError(ctx, it.ID, "no marketplaces configured for this item")
|
||||
return
|
||||
}
|
||||
apifyClient := s.apifyClient(ctx)
|
||||
var results []apify.UnifiedResult
|
||||
var errs []string
|
||||
successes := 0
|
||||
@@ -176,7 +185,7 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
|
||||
pcQueries = []string{""}
|
||||
}
|
||||
for _, q := range pcQueries {
|
||||
pcRaw, err := apifyClient.Run(ctx, pcID, apify.PriceComparisonInput{
|
||||
pcRaw, err := s.runApifyActor(ctx, pcID, apify.PriceComparisonInput{
|
||||
Query: q, URL: it.URL,
|
||||
ProxyConfiguration: s.proxyConfig(),
|
||||
})
|
||||
@@ -212,6 +221,10 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
|
||||
)
|
||||
|
||||
bestIdx := PickBest(results)
|
||||
// Resolve the deal-email recipient once: owner and Resend client are
|
||||
// constant for the whole poll, so per-result sends reuse them instead of
|
||||
// re-reading settings and the owner row for every alerting result.
|
||||
dealClient, dealOwner := s.dealMailer(ctx, it)
|
||||
alertsSent := 0
|
||||
for _, r := range results {
|
||||
exists, err := s.store.ResultExists(ctx, it.ID, r.URL)
|
||||
@@ -238,6 +251,11 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
|
||||
alerted = true
|
||||
alertsSent++
|
||||
}
|
||||
// Email the owner in addition to ntfy. Best-effort: a mail failure
|
||||
// must not block result storage or the next alert.
|
||||
if dealOwner != nil {
|
||||
s.sendDealEmail(ctx, dealClient, dealOwner, it, r)
|
||||
}
|
||||
}
|
||||
price := r.Price
|
||||
_, err = s.store.InsertResult(ctx, &models.Result{
|
||||
@@ -307,6 +325,44 @@ func (s *Scheduler) apifyClient(ctx context.Context) *apify.Client {
|
||||
return apify.New(key)
|
||||
}
|
||||
|
||||
// runApifyActor runs an Apify actor and records the call against the daily
|
||||
// usage counter that feeds the budget view. Every Apify-billed run in Veola
|
||||
// goes through here so the count stays honest.
|
||||
func (s *Scheduler) runApifyActor(ctx context.Context, actorID string, input any) ([]json.RawMessage, error) {
|
||||
raw, err := s.apifyClient(ctx).Run(ctx, actorID, input)
|
||||
if err != nil {
|
||||
// Count only runs that actually reached Apify. Counting before the call
|
||||
// would inflate the budget estimate with config errors and failed runs
|
||||
// that Apify never billed.
|
||||
return nil, err
|
||||
}
|
||||
if err := s.store.IncrementApifyUsage(ctx); err != nil {
|
||||
slog.Error("apify usage increment failed", "err", err)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// ApifyUsage reports Apify run counts and the cost model used by the budget
|
||||
// view. costPerCall and monthlyBudget come from settings, falling back to
|
||||
// config.toml.
|
||||
func (s *Scheduler) ApifyUsage(ctx context.Context) (today, month int, costPerCall, monthlyBudget float64) {
|
||||
today, _ = s.store.ApifyUsageToday(ctx)
|
||||
month, _ = s.store.ApifyUsageMonth(ctx)
|
||||
costPerCall = s.cfg.Budget.CostPerApifyCall
|
||||
if v, _ := s.store.GetSetting(ctx, "apify_cost_per_call"); v != "" {
|
||||
if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
|
||||
costPerCall = f
|
||||
}
|
||||
}
|
||||
monthlyBudget = s.cfg.Budget.MonthlyBudgetUSD
|
||||
if v, _ := s.store.GetSetting(ctx, "monthly_budget_usd"); v != "" {
|
||||
if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
|
||||
monthlyBudget = f
|
||||
}
|
||||
}
|
||||
return today, month, costPerCall, monthlyBudget
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -382,7 +438,7 @@ func (s *Scheduler) ExecutePlan(ctx context.Context, p actorPlan) ([]apify.Unifi
|
||||
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)
|
||||
raw, err := s.runApifyActor(ctx, p.actorID, p.input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -433,8 +489,7 @@ func (s *Scheduler) sendNotification(ctx context.Context, it models.Item, r apif
|
||||
if v, _ := s.store.GetSetting(ctx, "ntfy_base_url"); v != "" {
|
||||
baseURL = v
|
||||
}
|
||||
token, _ := s.store.GetSetting(ctx, "ntfy_token")
|
||||
client := ntfy.NewWithToken(baseURL, token)
|
||||
client := ntfy.NewWithBasicAuth(baseURL, s.cfg.Ntfy.Username, s.cfg.Ntfy.Password)
|
||||
return client.Send(ctx, ntfy.Notification{
|
||||
Topic: topic,
|
||||
Title: fmt.Sprintf("Veola Alert: %s", it.Name),
|
||||
@@ -677,7 +732,7 @@ func (s *Scheduler) seedSoldHistoryFor(ctx context.Context, it models.Item, quer
|
||||
if actorID == "" {
|
||||
return
|
||||
}
|
||||
raw, err := s.apifyClient(ctx).Run(ctx, actorID, apify.SoldListingInput{
|
||||
raw, err := s.runApifyActor(ctx, actorID, apify.SoldListingInput{
|
||||
Query: query, Marketplace: marketplace, MaxResults: 50, DaysBack: 30,
|
||||
ProxyConfiguration: s.proxyConfig(),
|
||||
})
|
||||
|
||||
12
main.go
12
main.go
@@ -68,6 +68,18 @@ func run(configPath string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth manager: %w", err)
|
||||
}
|
||||
if cfg.Auth.ForwardAuthEnabled() {
|
||||
fa, err := auth.NewForwardAuthConfig(
|
||||
cfg.Auth.ForwardHeaderUser, cfg.Auth.ForwardHeaderEmail,
|
||||
cfg.Auth.ForwardHeaderName, cfg.Auth.ForwardHeaderGroups,
|
||||
cfg.Auth.AdminGroup, cfg.Auth.TrustedProxies,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("forward-auth config: %w", err)
|
||||
}
|
||||
authMgr.SetForwardAuth(fa)
|
||||
slog.Info("forward-auth enabled", "trusted_proxies", cfg.Auth.TrustedProxies, "admin_group", cfg.Auth.AdminGroup)
|
||||
}
|
||||
|
||||
apifyClient := apify.New(cfg.Apify.APIKey)
|
||||
ntfyClient := ntfy.New(cfg.Ntfy.BaseURL)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/* Veola — Sega-blue palette and component overrides for Tailwind play CDN. */
|
||||
|
||||
:root {
|
||||
--bg: #1a2b6d;
|
||||
--surface: #1f3380;
|
||||
--surface-2: #243a93;
|
||||
--bg: #16224f;
|
||||
--surface: #243a93;
|
||||
--surface-2: #2c47ad;
|
||||
--accent: #00a4e4;
|
||||
--yellow: #f5c400;
|
||||
--text: #ffffff;
|
||||
@@ -25,59 +25,187 @@ html, body {
|
||||
a { color: var(--accent); }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* Solid surfaces, per the spec's "blue mode": no glassmorphism, no blur. A
|
||||
clear value gap between --bg and --surface is what lifts cards off the
|
||||
page; a 1px top highlight + soft blue shadow gives the cartridge edge. */
|
||||
.v-card {
|
||||
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%);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 40, 0.25);
|
||||
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);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 40, 0.3);
|
||||
}
|
||||
|
||||
.v-card-flat {
|
||||
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%);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.v-divider { border-top: 1px solid var(--border); }
|
||||
|
||||
/* --- Console readout / page header system -------------------------------
|
||||
The dashboard's signature: a mono "terminal" kicker over a heavy display
|
||||
title, and the Spend/Saved pair styled as an arcade scoreboard readout.
|
||||
JetBrains Mono is the machine voice here (labels, counts); Outfit carries
|
||||
the title. */
|
||||
|
||||
.v-kicker {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
.v-kicker .v-kicker-sep { color: var(--text-2); opacity: 0.6; }
|
||||
.v-kicker .v-kicker-dim { color: var(--text-2); }
|
||||
|
||||
/* --- Refined header + metric system -------------------------------------
|
||||
Calm and spacious: type and whitespace carry the page. No glow, no
|
||||
uppercase-mono labels, no accent rules. Mono is reserved for the numbers
|
||||
themselves; Outfit carries everything else. */
|
||||
|
||||
.v-page-title {
|
||||
font-family: 'Outfit', system-ui, sans-serif;
|
||||
font-size: 1.9rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.015em;
|
||||
line-height: 1.1;
|
||||
text-shadow: none;
|
||||
}
|
||||
.v-page-sub {
|
||||
color: var(--text-2);
|
||||
font-size: 0.95rem;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
/* Hero metric: a small quiet label over a large clean number. Presented in
|
||||
open space with a hairline, not a boxed card. */
|
||||
.v-metric-label {
|
||||
color: var(--text-2);
|
||||
font-size: 0.82rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.v-metric-value {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: clamp(2rem, 3.8vw, 2.75rem);
|
||||
font-weight: 600;
|
||||
line-height: 1.05;
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
.v-metric-value.v-price-deal { color: var(--success); }
|
||||
.v-metric-sub {
|
||||
color: var(--text-2);
|
||||
font-size: 0.82rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Secondary stat: smaller label/number pair for the supporting counts. */
|
||||
.v-stat-label {
|
||||
color: var(--text-2);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.v-stat-value {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
/* Understated section heading for list panels — sized to sit below the page
|
||||
title, sentence case, no glow. */
|
||||
.v-section-title {
|
||||
font-family: 'Outfit', system-ui, sans-serif;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
color: var(--text);
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
/* Hairline used to separate open sections instead of boxing them. */
|
||||
.v-rule {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Small uppercase group label inside forms (item form sections). */
|
||||
.v-form-section-title {
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
/* Quiet empty-state line. */
|
||||
.v-empty {
|
||||
color: var(--text-2);
|
||||
font-size: 0.9rem;
|
||||
padding: 0.4rem 0;
|
||||
}
|
||||
|
||||
/* --- Product feed (thumbnails) ------------------------------------------
|
||||
Listing thumbnails are what give the data life. A square cropped image,
|
||||
the listing title as the line that earns its space, price on the right. */
|
||||
.v-thumb {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
flex: none;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
display: block;
|
||||
}
|
||||
.v-thumb-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-2);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
/* Compact variant for table rows. */
|
||||
.v-thumb-sm { width: 40px; height: 40px; border-radius: 7px; }
|
||||
.v-thumb-sm.v-thumb-empty { font-size: 0.9rem; }
|
||||
|
||||
.v-feed { display: block; }
|
||||
.v-feed-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
padding: 0.7rem 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.v-feed-row:last-child { border-bottom: 0; }
|
||||
.v-feed-name {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
a.v-feed-name:hover { color: var(--accent); text-decoration: none; }
|
||||
.v-feed-meta {
|
||||
color: var(--text-2);
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
.v-feed-price { flex: none; white-space: nowrap; }
|
||||
|
||||
.v-btn {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
@@ -176,6 +304,23 @@ a:hover { text-decoration: underline; }
|
||||
}
|
||||
.v-side-nav a:hover { color: white; }
|
||||
|
||||
/* Sign-out button, styled to read like a nav link (it lives in a <form>). */
|
||||
.v-side-signout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
padding: 0.7rem 1rem;
|
||||
color: var(--text-2);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-left: 3px solid transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.v-side-signout: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. */
|
||||
@@ -212,21 +357,22 @@ a:hover { text-decoration: underline; }
|
||||
table.v-table { width: 100%; border-collapse: collapse; }
|
||||
table.v-table th {
|
||||
text-align: left;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--text-2);
|
||||
padding: 0.6rem 0.75rem;
|
||||
padding: 0.5rem 0.75rem 0.7rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
table.v-table td {
|
||||
padding: 0.7rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
vertical-align: middle;
|
||||
}
|
||||
/* No zebra — hairline rows and a faint hover keep it calm but scannable. */
|
||||
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); }
|
||||
table.v-table tbody tr:last-child td { border-bottom: 0; }
|
||||
table.v-table tbody tr:hover td { background: rgba(255, 255, 255, 0.04); }
|
||||
|
||||
.v-error-text { color: var(--danger); font-size: 0.85rem; }
|
||||
.v-muted { color: var(--text-2); }
|
||||
@@ -474,3 +620,18 @@ table.v-table tbody tr:hover td { background: rgba(0, 164, 228, 0.08); }
|
||||
.v-just-swapped { animation: none; }
|
||||
.v-countdown-critical { animation: none; }
|
||||
}
|
||||
|
||||
/* Budget progress bar (dashboard). Styled cross-browser without inline styles
|
||||
so it stays within the strict CSP (no style-src 'unsafe-inline'). */
|
||||
.v-progress {
|
||||
height: 0.6rem;
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.v-progress::-webkit-progress-bar { background: var(--surface-2); }
|
||||
.v-progress::-webkit-progress-value { background: var(--accent); }
|
||||
.v-progress::-moz-progress-bar { background: var(--accent); }
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/* Tailwind entry point. Compiled by the standalone CLI into tailwind.css:
|
||||
/* Tailwind v4 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;
|
||||
lives in app.css and is loaded separately, so editing it needs no rebuild.
|
||||
|
||||
v4 is CSS-first: utility classes are scanned from the @source globs below
|
||||
(templ files aren't auto-detected), and theme tokens go in @theme blocks. */
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
@source "../../templates/**/*.templ";
|
||||
|
||||
@@ -23,17 +23,20 @@ body {
|
||||
|
||||
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. */
|
||||
/* Refined direction: dot-grid and drifting blobs removed. A single calm,
|
||||
cool top-glow gradient gives the field depth without reading as a smudge
|
||||
or animating in the user's peripheral vision. */
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
rgba(255, 255, 255, 0.045) 1px,
|
||||
transparent 1px
|
||||
120% 75% at 50% -15%,
|
||||
#1f3074 0%,
|
||||
var(--bg) 58%
|
||||
);
|
||||
background-size: 26px 26px;
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
/* Retire the animated aurora blobs for the refined look. */
|
||||
html::before, html::after { display: none; }
|
||||
|
||||
/* 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
|
||||
@@ -48,13 +51,13 @@ html::before {
|
||||
pointer-events: none;
|
||||
background: radial-gradient(
|
||||
closest-side,
|
||||
rgba(0, 164, 228, 0.60),
|
||||
rgba(0, 164, 228, 0.13),
|
||||
rgba(0, 164, 228, 0.0) 70%
|
||||
);
|
||||
width: 70vmax;
|
||||
height: 70vmax;
|
||||
width: 60vmax;
|
||||
height: 60vmax;
|
||||
border-radius: 50%;
|
||||
filter: blur(60px);
|
||||
filter: blur(70px);
|
||||
/* Centered base; keyframes drive all motion from here. */
|
||||
top: 50%; left: 50%;
|
||||
margin-top: -35vmax; margin-left: -35vmax;
|
||||
@@ -68,15 +71,17 @@ html::after {
|
||||
inset: 0;
|
||||
z-index: -2;
|
||||
pointer-events: none;
|
||||
/* Cooled to a faint mint instead of arcade yellow: the warm blob read as a
|
||||
smudge against the cold palette. Kept subtle so it's ambient, not a wash. */
|
||||
background: radial-gradient(
|
||||
closest-side,
|
||||
rgba(245, 196, 0, 0.50),
|
||||
rgba(245, 196, 0, 0.0) 70%
|
||||
rgba(0, 228, 164, 0.07),
|
||||
rgba(0, 228, 164, 0.0) 70%
|
||||
);
|
||||
width: 55vmax;
|
||||
height: 55vmax;
|
||||
width: 46vmax;
|
||||
height: 46vmax;
|
||||
border-radius: 50%;
|
||||
filter: blur(70px);
|
||||
filter: blur(80px);
|
||||
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;
|
||||
@@ -104,13 +109,11 @@ html::after {
|
||||
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. */
|
||||
/* Refined direction: headings stay clean — no glow, modest weight. The
|
||||
page-title/section-title classes in app.css own the real hierarchy. */
|
||||
h1, h2 {
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
text-shadow: 0 2px 14px rgba(0, 164, 228, 0.30);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
16
static/vendor/chart.umd.min.js
vendored
16
static/vendor/chart.umd.min.js
vendored
File diff suppressed because one or more lines are too long
2
static/vendor/htmx.min.js
vendored
2
static/vendor/htmx.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -1,13 +0,0 @@
|
||||
/** @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: [],
|
||||
};
|
||||
@@ -13,6 +13,39 @@ type DashboardData struct {
|
||||
Stats *db.DashboardStats
|
||||
RecentResults []ResultRow
|
||||
RecentAlerts []AlertRow
|
||||
// Budget surface, shown to every signed-in user (Apify calls cost money).
|
||||
ApifyToday int
|
||||
ApifyMonth int
|
||||
ApifyCostPerCall float64
|
||||
MonthlyBudget float64
|
||||
EbayToday int
|
||||
EbayLimit int
|
||||
}
|
||||
|
||||
// ApifyMonthCost is the estimated month-to-date Apify spend in USD.
|
||||
func (d DashboardData) ApifyMonthCost() float64 {
|
||||
return float64(d.ApifyMonth) * d.ApifyCostPerCall
|
||||
}
|
||||
|
||||
// BudgetPercent is month-to-date spend as a percent of the monthly budget,
|
||||
// clamped to [0,100]. Zero when no budget is set.
|
||||
func (d DashboardData) BudgetPercent() int {
|
||||
if d.MonthlyBudget <= 0 {
|
||||
return 0
|
||||
}
|
||||
p := int(d.ApifyMonthCost() / d.MonthlyBudget * 100)
|
||||
if p < 0 {
|
||||
return 0
|
||||
}
|
||||
if p > 100 {
|
||||
return 100
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// BudgetOver reports whether estimated spend has exceeded the monthly budget.
|
||||
func (d DashboardData) BudgetOver() bool {
|
||||
return d.MonthlyBudget > 0 && d.ApifyMonthCost() > d.MonthlyBudget
|
||||
}
|
||||
|
||||
type ResultRow struct {
|
||||
@@ -23,14 +56,17 @@ type ResultRow struct {
|
||||
Currency string
|
||||
Source string
|
||||
URL string
|
||||
ImageURL string
|
||||
FoundAt time.Time
|
||||
Alerted bool
|
||||
}
|
||||
|
||||
type AlertRow struct {
|
||||
ItemName string
|
||||
Title string
|
||||
Price *float64
|
||||
Currency string
|
||||
ImageURL string
|
||||
FoundAt time.Time
|
||||
}
|
||||
|
||||
@@ -41,77 +77,166 @@ type AlertRow struct {
|
||||
// Layout would inject a nested page (and a duplicate sidebar) into the div.
|
||||
templ DashboardBody(d DashboardData) {
|
||||
<div hx-get="/dashboard/refresh" hx-trigger="every 60s" hx-swap="outerHTML">
|
||||
<h1 class="text-3xl font-semibold mb-6">Dashboard</h1>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
@statCard("Total Items", fmt.Sprintf("%d", d.Stats.TotalItems), "")
|
||||
@statCard("Active", fmt.Sprintf("%d", d.Stats.ActiveItems), "")
|
||||
@statCard("Results Today", fmt.Sprintf("%d", d.Stats.ResultsToday), "")
|
||||
@statCard("Alerts Today", fmt.Sprintf("%d", d.Stats.AlertsToday), "")
|
||||
<header class="mb-10">
|
||||
<h1 class="v-page-title">Dashboard</h1>
|
||||
<p class="v-page-sub">{ fmt.Sprintf("Watching %d items · %d active", d.Stats.TotalItems, d.Stats.ActiveItems) }</p>
|
||||
</header>
|
||||
<div class="grid md:grid-cols-2 gap-8 mb-8">
|
||||
<div>
|
||||
<div class="v-metric-label">Potential spend</div>
|
||||
<div class="v-metric-value" data-countup>{ fmt.Sprintf("$%.2f", d.Stats.PotentialSpend) }</div>
|
||||
<div class="v-metric-sub">
|
||||
{ fmt.Sprintf("across %d priced items", d.Stats.PricedItemCount) }
|
||||
if d.Stats.UnpricedCount > 0 {
|
||||
{ fmt.Sprintf(" · %d not yet priced", d.Stats.UnpricedCount) }
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="v-metric-label">Money saved</div>
|
||||
<div class="v-metric-value v-price-deal" data-countup>{ fmt.Sprintf("$%.2f", d.Stats.MoneySaved) }</div>
|
||||
<div class="v-metric-sub">{ fmt.Sprintf("across %d items, vs 30-day average", d.Stats.SavedItemCount) }</div>
|
||||
</div>
|
||||
</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>{ fmt.Sprintf("$%.2f", d.Stats.PotentialSpend) }</div>
|
||||
<div class="v-muted text-sm mt-1">across { fmt.Sprintf("%d", d.Stats.PricedItemCount) } items</div>
|
||||
if d.Stats.UnpricedCount > 0 {
|
||||
<div class="v-muted text-xs mt-1">{ fmt.Sprintf("%d items not yet priced.", d.Stats.UnpricedCount) }</div>
|
||||
<hr class="v-rule mb-8"/>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-6 mb-8">
|
||||
@statBlock("Total items", fmt.Sprintf("%d", d.Stats.TotalItems))
|
||||
@statBlock("Active", fmt.Sprintf("%d", d.Stats.ActiveItems))
|
||||
@statBlock("Results today", fmt.Sprintf("%d", d.Stats.ResultsToday))
|
||||
@statBlock("Alerts today", fmt.Sprintf("%d", d.Stats.AlertsToday))
|
||||
</div>
|
||||
<hr class="v-rule mb-8"/>
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-5">
|
||||
<h2 class="v-section-title">API usage and budget</h2>
|
||||
<span class="v-muted text-xs">Apify runs cost credits. Visible to everyone.</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<div>
|
||||
<div class="v-stat-label">Apify (month)</div>
|
||||
<div class="v-stat-value">{ fmt.Sprintf("%d", d.ApifyMonth) }</div>
|
||||
if d.ApifyCostPerCall > 0 {
|
||||
<div class="v-metric-sub">{ fmt.Sprintf("~$%.2f estimated", d.ApifyMonthCost()) }</div>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<div class="v-stat-label">Apify (today)</div>
|
||||
<div class="v-stat-value">{ fmt.Sprintf("%d", d.ApifyToday) }</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="v-stat-label">eBay (today)</div>
|
||||
if d.EbayLimit > 0 {
|
||||
<div class="v-stat-value">{ fmt.Sprintf("%d / %d", d.EbayToday, d.EbayLimit) }</div>
|
||||
} else {
|
||||
<div class="v-stat-value">{ fmt.Sprintf("%d", d.EbayToday) }</div>
|
||||
}
|
||||
</div>
|
||||
if d.MonthlyBudget > 0 {
|
||||
<div>
|
||||
<div class="v-stat-label">Monthly budget</div>
|
||||
<div class="v-stat-value">{ fmt.Sprintf("$%.0f", d.MonthlyBudget) }</div>
|
||||
</div>
|
||||
}
|
||||
</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>{ fmt.Sprintf("$%.2f", d.Stats.MoneySaved) }</div>
|
||||
<div class="v-muted text-sm mt-1">across { fmt.Sprintf("%d", d.Stats.SavedItemCount) } items</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="v-card p-5">
|
||||
<h2 class="font-semibold mb-3">Recent Results</h2>
|
||||
if d.MonthlyBudget > 0 {
|
||||
<div class="mt-6 max-w-md">
|
||||
<progress class="v-progress w-full" value={ fmt.Sprintf("%d", d.BudgetPercent()) } max="100"></progress>
|
||||
<div class="flex justify-between text-xs mt-2">
|
||||
<span class="v-muted">{ fmt.Sprintf("%d%% of budget used", d.BudgetPercent()) }</span>
|
||||
if d.BudgetOver() {
|
||||
<span class="v-price-target">Over budget</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
<hr class="v-rule mb-8"/>
|
||||
<div class="grid md:grid-cols-2 gap-10">
|
||||
<section>
|
||||
<h2 class="v-section-title mb-4">Recent results</h2>
|
||||
if len(d.RecentResults) == 0 {
|
||||
<div class="v-muted text-sm">No results yet.</div>
|
||||
<div class="v-empty">Nothing found yet. Veola is watching.</div>
|
||||
} else {
|
||||
<table class="v-table">
|
||||
<thead>
|
||||
<tr><th>Item</th><th>Price</th><th>Source</th><th>Found</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
for _, r := range d.RecentResults {
|
||||
<tr>
|
||||
<td><a href={ templ.SafeURL(fmt.Sprintf("/items/%d/results", r.ItemID)) }>{ r.ItemName }</a></td>
|
||||
<td class="font-mono">{ fmtPrice(r.Price, r.Currency) }</td>
|
||||
<td>{ r.Source }</td>
|
||||
<td class="v-muted text-sm">{ humanTime(r.FoundAt) }</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
<div class="v-card p-5">
|
||||
<h2 class="font-semibold mb-3">Recent Alerts</h2>
|
||||
if len(d.RecentAlerts) == 0 {
|
||||
<div class="v-muted text-sm">No alerts sent yet.</div>
|
||||
} else {
|
||||
<ul class="space-y-2">
|
||||
for _, a := range d.RecentAlerts {
|
||||
<li class="flex justify-between items-center border-b border-white/10 pb-2">
|
||||
<span>{ a.ItemName }</span>
|
||||
<span class="font-mono v-price-target">{ fmtPrice(a.Price, a.Currency) }</span>
|
||||
<ul class="v-feed">
|
||||
for _, r := range d.RecentResults {
|
||||
<li class="v-feed-row">
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/items/%d/results", r.ItemID)) }>
|
||||
@thumb(r.ImageURL, r.Source)
|
||||
</a>
|
||||
<div class="min-w-0 flex-1">
|
||||
<a class="v-feed-name" href={ templ.SafeURL(fmt.Sprintf("/items/%d/results", r.ItemID)) }>{ r.ItemName }</a>
|
||||
<div class="v-feed-meta truncate">{ resultMeta(r) }</div>
|
||||
</div>
|
||||
<div class="font-mono v-feed-price">{ fmtPrice(r.Price, r.Currency) }</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2 class="v-section-title mb-4">Recent alerts</h2>
|
||||
if len(d.RecentAlerts) == 0 {
|
||||
<div class="v-empty">No alerts sent. Nothing has hit target.</div>
|
||||
} else {
|
||||
<ul class="v-feed">
|
||||
for _, a := range d.RecentAlerts {
|
||||
<li class="v-feed-row">
|
||||
@thumb(a.ImageURL, "")
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="v-feed-name truncate">{ a.ItemName }</div>
|
||||
if a.Title != "" {
|
||||
<div class="v-feed-meta truncate">{ a.Title }</div>
|
||||
}
|
||||
</div>
|
||||
<div class="font-mono v-feed-price v-price-target">{ fmtPrice(a.Price, a.Currency) }</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ statCard(label, value, sub string) {
|
||||
<div class="v-card p-4">
|
||||
<div class="v-muted text-xs uppercase tracking-wide">{ label }</div>
|
||||
<div class="font-mono text-3xl mt-1" data-countup>{ value }</div>
|
||||
if sub != "" {
|
||||
<div class="v-muted text-xs mt-1">{ sub }</div>
|
||||
}
|
||||
// thumb renders a product thumbnail, falling back to a quiet placeholder
|
||||
// (the source initial) when a listing has no image. Marketplace images come
|
||||
// from an open set of CDNs, which the CSP's img-src allows.
|
||||
templ thumb(url, source string) {
|
||||
if url != "" {
|
||||
<img class="v-thumb" src={ url } loading="lazy" alt="" referrerpolicy="no-referrer"/>
|
||||
} else {
|
||||
<div class="v-thumb v-thumb-empty">{ sourceInitial(source) }</div>
|
||||
}
|
||||
}
|
||||
|
||||
// thumbSm is the compact table-row variant of thumb (no source placeholder
|
||||
// glyph — a plain quiet square when an item has no best-price image yet).
|
||||
templ thumbSm(url string) {
|
||||
if url != "" {
|
||||
<img class="v-thumb v-thumb-sm" src={ url } loading="lazy" alt="" referrerpolicy="no-referrer"/>
|
||||
} else {
|
||||
<div class="v-thumb v-thumb-sm v-thumb-empty"></div>
|
||||
}
|
||||
}
|
||||
|
||||
func resultMeta(r ResultRow) string {
|
||||
if r.Title != "" {
|
||||
return r.Title
|
||||
}
|
||||
return r.Source + " · " + humanTime(r.FoundAt)
|
||||
}
|
||||
|
||||
func sourceInitial(source string) string {
|
||||
if source == "" {
|
||||
return ""
|
||||
}
|
||||
return string([]rune(source)[:1])
|
||||
}
|
||||
|
||||
templ statBlock(label, value string) {
|
||||
<div>
|
||||
<div class="v-stat-label">{ label }</div>
|
||||
<div class="v-stat-value" data-countup>{ value }</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,39 @@ type DashboardData struct {
|
||||
Stats *db.DashboardStats
|
||||
RecentResults []ResultRow
|
||||
RecentAlerts []AlertRow
|
||||
// Budget surface, shown to every signed-in user (Apify calls cost money).
|
||||
ApifyToday int
|
||||
ApifyMonth int
|
||||
ApifyCostPerCall float64
|
||||
MonthlyBudget float64
|
||||
EbayToday int
|
||||
EbayLimit int
|
||||
}
|
||||
|
||||
// ApifyMonthCost is the estimated month-to-date Apify spend in USD.
|
||||
func (d DashboardData) ApifyMonthCost() float64 {
|
||||
return float64(d.ApifyMonth) * d.ApifyCostPerCall
|
||||
}
|
||||
|
||||
// BudgetPercent is month-to-date spend as a percent of the monthly budget,
|
||||
// clamped to [0,100]. Zero when no budget is set.
|
||||
func (d DashboardData) BudgetPercent() int {
|
||||
if d.MonthlyBudget <= 0 {
|
||||
return 0
|
||||
}
|
||||
p := int(d.ApifyMonthCost() / d.MonthlyBudget * 100)
|
||||
if p < 0 {
|
||||
return 0
|
||||
}
|
||||
if p > 100 {
|
||||
return 100
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// BudgetOver reports whether estimated spend has exceeded the monthly budget.
|
||||
func (d DashboardData) BudgetOver() bool {
|
||||
return d.MonthlyBudget > 0 && d.ApifyMonthCost() > d.MonthlyBudget
|
||||
}
|
||||
|
||||
type ResultRow struct {
|
||||
@@ -31,14 +64,17 @@ type ResultRow struct {
|
||||
Currency string
|
||||
Source string
|
||||
URL string
|
||||
ImageURL string
|
||||
FoundAt time.Time
|
||||
Alerted bool
|
||||
}
|
||||
|
||||
type AlertRow struct {
|
||||
ItemName string
|
||||
Title string
|
||||
Price *float64
|
||||
Currency string
|
||||
ImageURL string
|
||||
FoundAt time.Time
|
||||
}
|
||||
|
||||
@@ -68,243 +104,446 @@ func DashboardBody(d DashboardData) templ.Component {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div hx-get=\"/dashboard/refresh\" hx-trigger=\"every 60s\" hx-swap=\"outerHTML\"><h1 class=\"text-3xl font-semibold mb-6\">Dashboard</h1><div class=\"grid grid-cols-2 md:grid-cols-4 gap-4 mb-6\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = statCard("Total Items", fmt.Sprintf("%d", d.Stats.TotalItems), "").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = statCard("Active", fmt.Sprintf("%d", d.Stats.ActiveItems), "").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = statCard("Results Today", fmt.Sprintf("%d", d.Stats.ResultsToday), "").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = statCard("Alerts Today", fmt.Sprintf("%d", d.Stats.AlertsToday), "").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div><div class=\"grid md:grid-cols-2 gap-4 mb-6\"><div class=\"v-card p-5\"><div class=\"v-muted text-sm uppercase tracking-wide\">Potential Spend</div><div class=\"font-mono text-4xl mt-2\" data-countup>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div hx-get=\"/dashboard/refresh\" hx-trigger=\"every 60s\" hx-swap=\"outerHTML\"><header class=\"mb-10\"><h1 class=\"v-page-title\">Dashboard</h1><p class=\"v-page-sub\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.2f", d.Stats.PotentialSpend))
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Watching %d items · %d active", d.Stats.TotalItems, d.Stats.ActiveItems))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 54, Col: 100}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 82, Col: 113}
|
||||
}
|
||||
_, 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, 3, "</div><div class=\"v-muted text-sm mt-1\">across ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</p></header><div class=\"grid md:grid-cols-2 gap-8 mb-8\"><div><div class=\"v-metric-label\">Potential spend</div><div class=\"v-metric-value\" data-countup>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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("$%.2f", d.Stats.PotentialSpend))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 55, Col: 89}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 87, Col: 91}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " items</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div><div class=\"v-metric-sub\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("across %d priced items", d.Stats.PricedItemCount))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 89, Col: 69}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.Stats.UnpricedCount > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"v-muted text-xs mt-1\">")
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf(" · %d not yet priced", d.Stats.UnpricedCount))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 91, Col: 67}
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d items not yet priced.", d.Stats.UnpricedCount))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 57, Col: 103}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div>")
|
||||
_, 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, 7, "</div><div class=\"v-card p-5\"><div class=\"v-muted text-sm uppercase tracking-wide\">Money Saved</div><div class=\"font-mono text-4xl mt-2 v-price-deal\" data-countup>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.2f", d.Stats.MoneySaved))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 62, Col: 109}
|
||||
}
|
||||
_, 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, 8, "</div><div class=\"v-muted text-sm mt-1\">across ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div></div><div><div class=\"v-metric-label\">Money saved</div><div class=\"v-metric-value v-price-deal\" data-countup>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.Stats.SavedItemCount))
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.2f", d.Stats.MoneySaved))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 63, Col: 88}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 97, Col: 100}
|
||||
}
|
||||
_, 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, 9, " items</div></div></div><div class=\"grid md:grid-cols-2 gap-6\"><div class=\"v-card p-5\"><h2 class=\"font-semibold mb-3\">Recent Results</h2>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div><div class=\"v-metric-sub\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("across %d items, vs 30-day average", d.Stats.SavedItemCount))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 98, Col: 105}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div></div></div><hr class=\"v-rule mb-8\"><div class=\"grid grid-cols-2 md:grid-cols-4 gap-6 mb-8\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = statBlock("Total items", fmt.Sprintf("%d", d.Stats.TotalItems)).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = statBlock("Active", fmt.Sprintf("%d", d.Stats.ActiveItems)).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = statBlock("Results today", fmt.Sprintf("%d", d.Stats.ResultsToday)).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = statBlock("Alerts today", fmt.Sprintf("%d", d.Stats.AlertsToday)).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div><hr class=\"v-rule mb-8\"><section class=\"mb-10\"><div class=\"flex items-baseline justify-between mb-5\"><h2 class=\"v-section-title\">API usage and budget</h2><span class=\"v-muted text-xs\">Apify runs cost credits. Visible to everyone.</span></div><div class=\"grid grid-cols-2 md:grid-cols-4 gap-6\"><div><div class=\"v-stat-label\">Apify (month)</div><div class=\"v-stat-value\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.ApifyMonth))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 117, Col: 64}
|
||||
}
|
||||
_, 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, 9, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.ApifyCostPerCall > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"v-metric-sub\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("~$%.2f estimated", d.ApifyMonthCost()))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 119, Col: 85}
|
||||
}
|
||||
_, 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, 11, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div><div><div class=\"v-stat-label\">Apify (today)</div><div class=\"v-stat-value\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.ApifyToday))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 124, Col: 64}
|
||||
}
|
||||
_, 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, 13, "</div></div><div><div class=\"v-stat-label\">eBay (today)</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.EbayLimit > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"v-stat-value\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d", d.EbayToday, d.EbayLimit))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 129, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
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
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"v-stat-value\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.EbayToday))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 131, Col: 64}
|
||||
}
|
||||
_, 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, 17, "</div>")
|
||||
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.MonthlyBudget > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div><div class=\"v-stat-label\">Monthly budget</div><div class=\"v-stat-value\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.0f", d.MonthlyBudget))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 137, Col: 71}
|
||||
}
|
||||
_, 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, 20, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.MonthlyBudget > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<div class=\"mt-6 max-w-md\"><progress class=\"v-progress w-full\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", d.BudgetPercent()))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 143, Col: 85}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" max=\"100\"></progress><div class=\"flex justify-between text-xs mt-2\"><span 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(fmt.Sprintf("%d%% of budget used", d.BudgetPercent()))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 145, Col: 83}
|
||||
}
|
||||
_, 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, 24, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.BudgetOver() {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<span class=\"v-price-target\">Over budget</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</section><hr class=\"v-rule mb-8\"><div class=\"grid md:grid-cols-2 gap-10\"><section><h2 class=\"v-section-title mb-4\">Recent results</h2>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(d.RecentResults) == 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"v-muted text-sm\">No results yet.</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<div class=\"v-empty\">Nothing found yet. Veola is watching.</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<table class=\"v-table\"><thead><tr><th>Item</th><th>Price</th><th>Source</th><th>Found</th></tr></thead> <tbody>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<ul class=\"v-feed\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, r := range d.RecentResults {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<tr><td><a href=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<li class=\"v-feed-row\"><a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 templ.SafeURL
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results", r.ItemID)))
|
||||
var templ_7745c5c3_Var16 templ.SafeURL
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results", r.ItemID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 79, Col: 80}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 163, Col: 75}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.ItemName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 79, Col: 95}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
templ_7745c5c3_Err = thumb(r.ImageURL, r.Source).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</a></td><td class=\"font-mono\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</a><div class=\"min-w-0 flex-1\"><a class=\"v-feed-name\" href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(r.Price, r.Currency))
|
||||
var templ_7745c5c3_Var17 templ.SafeURL
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results", r.ItemID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 80, Col: 62}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 167, Col: 96}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
_, 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, 15, "</td><td>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(r.Source)
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(r.ItemName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 81, Col: 23}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 167, Col: 111}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
_, 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, 16, "</td><td class=\"v-muted text-sm\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</a><div class=\"v-feed-meta truncate\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(r.FoundAt))
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(resultMeta(r))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 82, Col: 59}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 168, Col: 58}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</td></tr>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</div></div><div class=\"font-mono v-feed-price\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(r.Price, r.Currency))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 170, Col: 75}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</div></li>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</tbody></table>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</ul>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div><div class=\"v-card p-5\"><h2 class=\"font-semibold mb-3\">Recent Alerts</h2>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</section><section><h2 class=\"v-section-title mb-4\">Recent alerts</h2>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(d.RecentAlerts) == 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"v-muted text-sm\">No alerts sent yet.</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<div class=\"v-empty\">No alerts sent. Nothing has hit target.</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<ul class=\"space-y-2\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<ul class=\"v-feed\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, a := range d.RecentAlerts {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<li class=\"flex justify-between items-center border-b border-white/10 pb-2\"><span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<li class=\"v-feed-row\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(a.ItemName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 97, Col: 26}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
templ_7745c5c3_Err = thumb(a.ImageURL, "").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</span> <span class=\"font-mono v-price-target\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<div class=\"min-w-0 flex-1\"><div class=\"v-feed-name truncate\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(a.Price, a.Currency))
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(a.ItemName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 98, Col: 78}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 186, Col: 55}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
_, 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, 24, "</span></li>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if a.Title != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<div class=\"v-feed-meta truncate\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(a.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 188, Col: 53}
|
||||
}
|
||||
_, 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, 45, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</div><div class=\"font-mono v-feed-price v-price-target\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(a.Price, a.Currency))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 191, Col: 90}
|
||||
}
|
||||
_, 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, 47, "</div></li>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</ul>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</ul>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</section></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -312,7 +551,10 @@ func DashboardBody(d DashboardData) templ.Component {
|
||||
})
|
||||
}
|
||||
|
||||
func statCard(label, value, sub string) templ.Component {
|
||||
// thumb renders a product thumbnail, falling back to a quiet placeholder
|
||||
// (the source initial) when a listing has no image. Marketplace images come
|
||||
// from an open set of CDNs, which the CSP's img-src allows.
|
||||
func thumb(url, source string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
@@ -328,61 +570,165 @@ func statCard(label, value, sub string) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var14 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var14 == nil {
|
||||
templ_7745c5c3_Var14 = templ.NopComponent
|
||||
templ_7745c5c3_Var24 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var24 == nil {
|
||||
templ_7745c5c3_Var24 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<div class=\"v-card p-4\"><div class=\"v-muted text-xs uppercase tracking-wide\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(label)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 110, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div><div class=\"font-mono text-3xl mt-1\" data-countup>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(value)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 111, Col: 59}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if sub != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<div class=\"v-muted text-xs mt-1\">")
|
||||
if url != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<img class=\"v-thumb\" src=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(sub)
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(url)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 113, Col: 42}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 206, Col: 32}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
_, 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, 31, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\" loading=\"lazy\" alt=\"\" referrerpolicy=\"no-referrer\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<div class=\"v-thumb v-thumb-empty\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var26 string
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(sourceInitial(source))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 208, Col: 60}
|
||||
}
|
||||
_, 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, 53, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div>")
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// thumbSm is the compact table-row variant of thumb (no source placeholder
|
||||
// glyph — a plain quiet square when an item has no best-price image yet).
|
||||
func thumbSm(url string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
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_Var27 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var27 == nil {
|
||||
templ_7745c5c3_Var27 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
if url != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<img class=\"v-thumb v-thumb-sm\" src=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var28 string
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(url)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 216, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\" loading=\"lazy\" alt=\"\" referrerpolicy=\"no-referrer\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<div class=\"v-thumb v-thumb-sm v-thumb-empty\"></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func resultMeta(r ResultRow) string {
|
||||
if r.Title != "" {
|
||||
return r.Title
|
||||
}
|
||||
return r.Source + " · " + humanTime(r.FoundAt)
|
||||
}
|
||||
|
||||
func sourceInitial(source string) string {
|
||||
if source == "" {
|
||||
return ""
|
||||
}
|
||||
return string([]rune(source)[:1])
|
||||
}
|
||||
|
||||
func statBlock(label, value string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
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_Var29 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var29 == nil {
|
||||
templ_7745c5c3_Var29 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<div><div class=\"v-stat-label\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(label)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 238, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</div><div class=\"v-stat-value\" data-countup>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(value)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 239, Col: 48}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -406,9 +752,9 @@ func Dashboard(d DashboardData) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var18 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var18 == nil {
|
||||
templ_7745c5c3_Var18 = templ.NopComponent
|
||||
templ_7745c5c3_Var32 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var32 == nil {
|
||||
templ_7745c5c3_Var32 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = Layout(d.Page, DashboardBody(d)).Render(ctx, templ_7745c5c3_Buffer)
|
||||
|
||||
@@ -136,13 +136,16 @@ func newCategory(v string, known []string) string {
|
||||
|
||||
templ itemFormBody(d ItemFormData) {
|
||||
<div>
|
||||
<h1 class="text-3xl font-semibold mb-6">
|
||||
if d.IsEdit {
|
||||
Edit { d.Item.Name }
|
||||
} else {
|
||||
Add Item
|
||||
}
|
||||
</h1>
|
||||
<div class="mb-6">
|
||||
<h1 class="v-page-title">
|
||||
if d.IsEdit {
|
||||
Edit { d.Item.Name }
|
||||
} else {
|
||||
Add item
|
||||
}
|
||||
</h1>
|
||||
<p class="v-page-sub">What to watch, where to look, and when to alert.</p>
|
||||
</div>
|
||||
if len(d.Errors) > 0 {
|
||||
<div class="v-flash-error">
|
||||
<ul class="list-disc pl-5">
|
||||
@@ -167,9 +170,10 @@ templ itemFormInner(d ItemFormData) {
|
||||
hx-indicator="#preview-loading"
|
||||
hx-disabled-elt="find button[type='submit']"
|
||||
}
|
||||
class="v-card p-6 space-y-4 max-w-3xl"
|
||||
class="v-card p-6 space-y-5 max-w-3xl"
|
||||
>
|
||||
@CSRFInput(d.CSRFToken)
|
||||
<div class="v-form-section-title">Basics</div>
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="v-label">Name</label>
|
||||
@@ -196,6 +200,8 @@ templ itemFormInner(d ItemFormData) {
|
||||
<input class="v-input" name="url" value={ d.Item.URL }/>
|
||||
<div class="v-muted text-xs mt-1">At least one of search queries or URL is required.</div>
|
||||
</div>
|
||||
<hr class="v-rule"/>
|
||||
<div class="v-form-section-title">Alerts</div>
|
||||
<div class="grid md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="v-label">Target Price</label>
|
||||
@@ -215,6 +221,8 @@ templ itemFormInner(d ItemFormData) {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="v-rule"/>
|
||||
<div class="v-form-section-title">Schedule and marketplaces</div>
|
||||
<div class="grid md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="v-label">Poll Interval</label>
|
||||
@@ -251,6 +259,8 @@ templ itemFormInner(d ItemFormData) {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="v-rule"/>
|
||||
<div class="v-form-section-title">Filters</div>
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="v-label">Minimum Price</label>
|
||||
@@ -287,8 +297,9 @@ templ itemFormInner(d ItemFormData) {
|
||||
<input type="checkbox" name="include_out_of_stock" checked?={ d.Item.IncludeOutOfStock } value="1"/>
|
||||
<span>Include out-of-stock results</span>
|
||||
</label>
|
||||
<hr class="v-rule"/>
|
||||
<details class="v-card-flat p-4">
|
||||
<summary class="cursor-pointer font-semibold">Advanced</summary>
|
||||
<summary class="cursor-pointer v-form-section-title">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>
|
||||
|
||||
@@ -163,7 +163,7 @@ func itemFormBody(d ItemFormData) templ.Component {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div><h1 class=\"text-3xl font-semibold mb-6\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div><div class=\"mb-6\"><h1 class=\"v-page-title\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -175,19 +175,19 @@ func itemFormBody(d ItemFormData) templ.Component {
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 141, Col: 22}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 142, Col: 23}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "Add Item")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "Add item")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</h1>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</h1><p class=\"v-page-sub\">What to watch, where to look, and when to alert.</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -204,7 +204,7 @@ func itemFormBody(d ItemFormData) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(e)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 150, Col: 13}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 153, Col: 13}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -260,7 +260,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
||||
var templ_7745c5c3_Var5 templ.SafeURL
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(formAction(d))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 162, Col: 24}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 165, Col: 24}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -276,7 +276,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " class=\"v-card p-6 space-y-4 max-w-3xl\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " class=\"v-card p-6 space-y-5 max-w-3xl\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -284,14 +284,14 @@ func itemFormInner(d ItemFormData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"grid md:grid-cols-2 gap-4\"><div><label class=\"v-label\">Name</label> <input class=\"v-input\" name=\"name\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"v-form-section-title\">Basics</div><div class=\"grid md:grid-cols-2 gap-4\"><div><label class=\"v-label\">Name</label> <input class=\"v-input\" name=\"name\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 176, Col: 58}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 180, Col: 58}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -319,7 +319,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(c)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 183, Col: 23}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 187, Col: 23}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -342,7 +342,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(c)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 183, Col: 64}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 187, Col: 64}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -360,7 +360,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(newCategory(d.Item.Category, d.Categories))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 186, Col: 110}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 190, Col: 110}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -373,7 +373,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.SearchQuery)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 191, Col: 150}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 195, Col: 150}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -386,20 +386,20 @@ func itemFormInner(d ItemFormData) templ.Component {
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.URL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 196, Col: 55}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 200, Col: 55}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\"><div class=\"v-muted text-xs mt-1\">At least one of search queries or URL is required.</div></div><div class=\"grid md:grid-cols-3 gap-4\"><div><label class=\"v-label\">Target Price</label> <input class=\"v-input font-mono\" name=\"target_price\" type=\"number\" step=\"0.01\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\"><div class=\"v-muted text-xs mt-1\">At least one of search queries or URL is required.</div></div><hr class=\"v-rule\"><div class=\"v-form-section-title\">Alerts</div><div class=\"grid md:grid-cols-3 gap-4\"><div><label class=\"v-label\">Target Price</label> <input class=\"v-input font-mono\" name=\"target_price\" type=\"number\" step=\"0.01\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(optFloat(d.Item.TargetPrice))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 202, Col: 119}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 208, Col: 119}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -412,7 +412,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.NtfyTopic)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 207, Col: 69}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 213, Col: 69}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -430,7 +430,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(p)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 213, Col: 23}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 219, Col: 23}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -453,7 +453,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(p)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 213, Col: 80}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 219, Col: 80}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -464,7 +464,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</select></div></div><div class=\"grid md:grid-cols-3 gap-4\"><div><label class=\"v-label\">Poll Interval</label> <select class=\"v-select\" name=\"poll_interval_minutes\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</select></div></div><hr class=\"v-rule\"><div class=\"v-form-section-title\">Schedule and marketplaces</div><div class=\"grid md:grid-cols-3 gap-4\"><div><label class=\"v-label\">Poll Interval</label> <select class=\"v-select\" name=\"poll_interval_minutes\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -476,7 +476,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", opt.Minutes))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 223, Col: 52}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 231, Col: 52}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -499,7 +499,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 223, Col: 122}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 231, Col: 122}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -522,7 +522,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.Value)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 232, Col: 64}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 240, Col: 64}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -545,7 +545,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(m.Label)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 233, Col: 22}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 241, Col: 22}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -563,7 +563,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(customMarketplacesCSV(d.Item))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 237, Col: 103}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 245, Col: 103}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -591,7 +591,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(lt)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 249, Col: 24}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 257, Col: 24}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -614,7 +614,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(lt)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 249, Col: 82}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 257, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -625,14 +625,14 @@ func itemFormInner(d ItemFormData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</select></div></div><div class=\"grid md:grid-cols-2 gap-4\"><div><label class=\"v-label\">Minimum Price</label> <input class=\"v-input font-mono\" name=\"min_price\" type=\"number\" step=\"0.01\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</select></div></div><hr class=\"v-rule\"><div class=\"v-form-section-title\">Filters</div><div class=\"grid md:grid-cols-2 gap-4\"><div><label class=\"v-label\">Minimum Price</label> <input class=\"v-input font-mono\" name=\"min_price\" type=\"number\" step=\"0.01\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(optFloat(d.Item.MinPrice))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 257, Col: 113}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 267, Col: 113}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -645,7 +645,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.ExcludeKeywords)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 262, Col: 137}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 272, Col: 137}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -663,7 +663,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
||||
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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 281, Col: 29}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -686,7 +686,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
||||
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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 281, Col: 95}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -709,7 +709,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
||||
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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 290, Col: 29}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -732,7 +732,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
||||
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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 290, Col: 92}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -753,14 +753,14 @@ func itemFormInner(d ItemFormData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
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=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, " value=\"1\"> <span>Include out-of-stock results</span></label><hr class=\"v-rule\"><details class=\"v-card-flat p-4\"><summary class=\"cursor-pointer v-form-section-title\">Advanced</summary><div class=\"v-muted text-sm mt-2\">Leave blank to use the configured default for the selected marketplace.</div><div class=\"grid md:grid-cols-2 gap-4 mt-3\"><div><label class=\"v-label\">Active Listings Actor</label> <input class=\"v-input\" name=\"actor_active\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.ActorActive)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 296, Col: 74}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 307, Col: 74}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -773,7 +773,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.ActorSold)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 300, Col: 70}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 311, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -786,7 +786,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.ActorPriceCompare)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 304, Col: 87}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 315, Col: 87}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -52,13 +52,13 @@ templ ItemPreview(d PreviewData) {
|
||||
</div>
|
||||
} else if d.Empty {
|
||||
<div class="v-flash">
|
||||
<div class="font-semibold mb-1">No results found</div>
|
||||
<div>Try a broader search query or a different marketplace.</div>
|
||||
<div class="font-semibold mb-1">Nothing listed right now</div>
|
||||
<div>Track it anyway. Veola will alert you when it appears. Leave the target price blank to be notified on any new listing.</div>
|
||||
</div>
|
||||
} else {
|
||||
<div class="v-card p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="font-semibold">
|
||||
<h2 class="v-section-title">
|
||||
{ fmt.Sprintf("Found %d results for '%s'", len(d.Results), d.Form.SearchQuery) }
|
||||
if d.Cached {
|
||||
<span class="v-pill v-pill-paused ml-2">cached</span>
|
||||
@@ -67,20 +67,18 @@ templ ItemPreview(d PreviewData) {
|
||||
</div>
|
||||
@previewBest(d)
|
||||
if len(d.Results) > 1 {
|
||||
<div class="mt-5">
|
||||
<div class="v-muted text-xs uppercase tracking-wide mb-2">Other results</div>
|
||||
<ul class="space-y-2">
|
||||
<div class="mt-6">
|
||||
<div class="v-form-section-title mb-3">Other results</div>
|
||||
<ul class="v-feed">
|
||||
for i, r := range d.Results {
|
||||
if i != d.BestIndex && i < d.BestIndex+6 {
|
||||
<li class="flex items-center gap-3">
|
||||
if r.ImageURL != "" {
|
||||
<img src={ r.ImageURL } alt="" class="w-10 h-10 object-cover rounded"/>
|
||||
}
|
||||
<div class="flex-1 truncate">
|
||||
<a href={ templ.SafeURL(r.URL) } target="_blank" rel="noopener" class="text-sm">{ r.Title }</a>
|
||||
<div class="v-muted text-xs">{ r.Store }</div>
|
||||
<li class="v-feed-row">
|
||||
@thumbSm(r.ImageURL)
|
||||
<div class="min-w-0 flex-1">
|
||||
<a href={ templ.SafeURL(r.URL) } target="_blank" rel="noopener" class="v-feed-name truncate">{ r.Title }</a>
|
||||
<div class="v-feed-meta truncate">{ r.Store }</div>
|
||||
</div>
|
||||
<div class="font-mono">{ fmtNumber(r.Price, r.Currency) }</div>
|
||||
<div class="font-mono v-feed-price">{ fmtNumber(r.Price, r.Currency) }</div>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
@@ -103,12 +101,12 @@ templ previewBest(d PreviewData) {
|
||||
if d.BestIndex >= 0 && d.BestIndex < len(d.Results) {
|
||||
<div class="grid md:grid-cols-[160px_1fr] gap-4 items-start">
|
||||
if d.Results[d.BestIndex].ImageURL != "" {
|
||||
<img src={ d.Results[d.BestIndex].ImageURL } alt="" class="rounded w-40 h-40 object-cover"/>
|
||||
<img src={ d.Results[d.BestIndex].ImageURL } alt="" loading="lazy" referrerpolicy="no-referrer" class="w-40 h-40 object-cover rounded-lg border border-white/10"/>
|
||||
} else {
|
||||
<div class="rounded w-40 h-40 bg-black/30"></div>
|
||||
<div class="w-40 h-40 rounded-lg border border-white/10 bg-black/20"></div>
|
||||
}
|
||||
<div>
|
||||
<div class="text-xs v-price-deal uppercase tracking-wide mb-1">Best Price</div>
|
||||
<div class="v-metric-label v-price-deal mb-1">Best price</div>
|
||||
<div class="font-mono text-3xl mb-2">{ fmtNumber(d.Results[d.BestIndex].Price, d.Currency) }</div>
|
||||
<a class="font-semibold" href={ templ.SafeURL(d.Results[d.BestIndex].URL) } target="_blank" rel="noopener">{ d.Results[d.BestIndex].Title }</a>
|
||||
<div class="v-muted text-sm mt-1">{ d.Results[d.BestIndex].Store }</div>
|
||||
@@ -144,7 +142,7 @@ templ confirmForm(d PreviewData) {
|
||||
@hidden("actor_sold", d.Form.ActorSold)
|
||||
@hidden("actor_price_compare", d.Form.ActorPriceCompare)
|
||||
@hiddenBool("use_price_comparison", d.Form.UsePriceComparison)
|
||||
<button type="submit" class="v-btn" disabled?={ d.Empty || d.Error != "" }>Confirm and Track</button>
|
||||
<button type="submit" class="v-btn" disabled?={ d.Error != "" }>Confirm and Track</button>
|
||||
<a class="v-btn-ghost" href="/items/new">Back</a>
|
||||
</form>
|
||||
}
|
||||
|
||||
@@ -91,12 +91,12 @@ func ItemPreview(d PreviewData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if d.Empty {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"v-flash\"><div class=\"font-semibold mb-1\">No results found</div><div>Try a broader search query or a different marketplace.</div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"v-flash\"><div class=\"font-semibold mb-1\">Nothing listed right now</div><div>Track it anyway. Veola will alert you when it appears. Leave the target price blank to be notified on any new listing.</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"v-card p-5\"><div class=\"flex items-center justify-between mb-4\"><h2 class=\"font-semibold\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"v-card p-5\"><div class=\"flex items-center justify-between mb-4\"><h2 class=\"v-section-title\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -128,135 +128,120 @@ func ItemPreview(d PreviewData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(d.Results) > 1 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"mt-5\"><div class=\"v-muted text-xs uppercase tracking-wide mb-2\">Other results</div><ul class=\"space-y-2\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"mt-6\"><div class=\"v-form-section-title mb-3\">Other results</div><ul class=\"v-feed\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for i, r := range d.Results {
|
||||
if i != d.BestIndex && i < d.BestIndex+6 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<li class=\"flex items-center gap-3\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<li class=\"v-feed-row\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if r.ImageURL != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<img src=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ImageURL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 77, Col: 31}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" alt=\"\" class=\"w-10 h-10 object-cover rounded\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"flex-1 truncate\"><a href=\"")
|
||||
templ_7745c5c3_Err = thumbSm(r.ImageURL).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 templ.SafeURL
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.URL))
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"min-w-0 flex-1\"><a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 80, Col: 40}
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 templ.SafeURL
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.URL))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 78, Col: 40}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" target=\"_blank\" rel=\"noopener\" class=\"v-feed-name truncate\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 78, Col: 112}
|
||||
}
|
||||
_, 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, 13, "\" target=\"_blank\" rel=\"noopener\" class=\"text-sm\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</a><div class=\"v-feed-meta truncate\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(r.Store)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 80, Col: 99}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 79, Col: 53}
|
||||
}
|
||||
_, 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, 14, "</a><div class=\"v-muted text-xs\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div></div><div class=\"font-mono v-feed-price\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(r.Store)
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmtNumber(r.Price, r.Currency))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 81, Col: 48}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 81, Col: 77}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div></div><div class=\"font-mono\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmtNumber(r.Price, r.Currency))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 83, Col: 64}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div></li>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div></li>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</ul>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</ul>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(d.Results) > 6 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"v-muted text-xs mt-2\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"v-muted text-xs mt-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("and %d more", len(d.Results)-6))
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("and %d more", len(d.Results)-6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 89, Col: 86}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 87, 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_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<div class=\"v-divider my-4\"></div><div class=\"text-sm v-muted\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"v-divider my-4\"></div><div class=\"text-sm v-muted\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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))
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Prices range from %s to %s across %d stores", fmtNumber(d.MinPrice, d.Currency), fmtNumber(d.MaxPrice, d.Currency), d.StoreCount))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 95, Col: 148}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 93, 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_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -285,116 +270,116 @@ func previewBest(d PreviewData) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var11 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var11 == nil {
|
||||
templ_7745c5c3_Var11 = templ.NopComponent
|
||||
templ_7745c5c3_Var10 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var10 == nil {
|
||||
templ_7745c5c3_Var10 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
if d.BestIndex >= 0 && d.BestIndex < len(d.Results) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"grid md:grid-cols-[160px_1fr] gap-4 items-start\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<div class=\"grid md:grid-cols-[160px_1fr] gap-4 items-start\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.Results[d.BestIndex].ImageURL != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<img src=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<img src=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Results[d.BestIndex].ImageURL)
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Results[d.BestIndex].ImageURL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 106, Col: 46}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 104, Col: 46}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" alt=\"\" class=\"rounded w-40 h-40 object-cover\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" alt=\"\" loading=\"lazy\" referrerpolicy=\"no-referrer\" class=\"w-40 h-40 object-cover rounded-lg border border-white/10\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<div class=\"rounded w-40 h-40 bg-black/30\"></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"w-40 h-40 rounded-lg border border-white/10 bg-black/20\"></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<div><div class=\"text-xs v-price-deal uppercase tracking-wide mb-1\">Best Price</div><div class=\"font-mono text-3xl mb-2\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div><div class=\"v-metric-label v-price-deal mb-1\">Best price</div><div class=\"font-mono text-3xl mb-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmtNumber(d.Results[d.BestIndex].Price, d.Currency))
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmtNumber(d.Results[d.BestIndex].Price, d.Currency))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 112, Col: 94}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 110, Col: 94}
|
||||
}
|
||||
_, 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><a class=\"font-semibold\" href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 templ.SafeURL
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(d.Results[d.BestIndex].URL))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 111, Col: 77}
|
||||
}
|
||||
_, 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><a class=\"font-semibold\" href=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" target=\"_blank\" rel=\"noopener\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 templ.SafeURL
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(d.Results[d.BestIndex].URL))
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(d.Results[d.BestIndex].Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 113, Col: 77}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 111, Col: 141}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" target=\"_blank\" rel=\"noopener\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</a><div class=\"v-muted text-sm mt-1\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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].Store)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 113, Col: 141}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 112, Col: 68}
|
||||
}
|
||||
_, 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, 30, "</a><div class=\"v-muted text-sm mt-1\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(d.Results[d.BestIndex].Store)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 114, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.Results[d.BestIndex].MatchedQuery != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<div class=\"v-muted text-xs mt-1\">via \"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<div class=\"v-muted text-xs mt-1\">via \"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(d.Results[d.BestIndex].MatchedQuery)
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(d.Results[d.BestIndex].MatchedQuery)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 116, Col: 81}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 114, 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_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\"</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\"</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -419,25 +404,25 @@ func confirmForm(d PreviewData) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var18 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var18 == nil {
|
||||
templ_7745c5c3_Var18 = templ.NopComponent
|
||||
templ_7745c5c3_Var17 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var17 == nil {
|
||||
templ_7745c5c3_Var17 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<form method=\"post\" action=\"/items\" class=\"mt-5 flex items-center gap-3\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<form method=\"post\" action=\"/items\" class=\"mt-5 flex items-center gap-3\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 125, Col: 60}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 123, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -519,17 +504,17 @@ func confirmForm(d PreviewData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<button type=\"submit\" class=\"v-btn\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<button type=\"submit\" class=\"v-btn\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.Empty || d.Error != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " disabled")
|
||||
if d.Error != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " disabled")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, ">Confirm and Track</button> <a class=\"v-btn-ghost\" href=\"/items/new\">Back</a></form>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">Confirm and Track</button> <a class=\"v-btn-ghost\" href=\"/items/new\">Back</a></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -553,38 +538,38 @@ func hidden(name, value string) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var20 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var20 == nil {
|
||||
templ_7745c5c3_Var20 = templ.NopComponent
|
||||
templ_7745c5c3_Var19 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var19 == nil {
|
||||
templ_7745c5c3_Var19 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<input type=\"hidden\" name=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<input type=\"hidden\" name=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 151, Col: 33}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(name)
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(value)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 153, Col: 33}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 151, Col: 49}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(value)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 153, Col: 49}
|
||||
}
|
||||
_, 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, 42, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -608,26 +593,26 @@ func hiddenBool(name string, value bool) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var23 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var23 == nil {
|
||||
templ_7745c5c3_Var23 = templ.NopComponent
|
||||
templ_7745c5c3_Var22 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var22 == nil {
|
||||
templ_7745c5c3_Var22 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
if value {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<input type=\"hidden\" name=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<input type=\"hidden\" name=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(name)
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 158, Col: 34}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 156, Col: 34}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||
_, 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, "\" value=\"1\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\" value=\"1\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -96,9 +96,12 @@ func isDeal(it models.Item) bool {
|
||||
|
||||
templ itemsBody(d ItemsData) {
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-semibold">Items</h1>
|
||||
<a class="v-btn" href="/items/new">+ Add Item</a>
|
||||
<div class="flex items-end justify-between mb-8">
|
||||
<div>
|
||||
<h1 class="v-page-title">Items</h1>
|
||||
<p class="v-page-sub">{ fmt.Sprintf("%d on the watchlist", len(d.Items)) }</p>
|
||||
</div>
|
||||
<a class="v-btn" href="/items/new">Add item</a>
|
||||
</div>
|
||||
if len(d.Categories) > 0 {
|
||||
<form method="get" action="/items" class="mb-4 flex items-center gap-2">
|
||||
@@ -155,16 +158,21 @@ templ itemsEmpty() {
|
||||
templ itemRow(it models.Item, csrf string, history []models.PricePoint) {
|
||||
<tr id={ fmt.Sprintf("item-row-%d", it.ID) }>
|
||||
<td>
|
||||
<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 class="flex items-center gap-3">
|
||||
@thumbSm(it.BestPriceImageURL)
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
if isDeal(it) {
|
||||
<img class="v-deal-mascot" src="/static/img/veola.avif" alt="" title={ fmt.Sprintf("Deal! Best %s ≤ target %s", fmtPrice(it.BestPrice, it.BestPriceCurrency), fmtPrice(it.TargetPrice, "USD")) }/>
|
||||
}
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/items/%d/results", it.ID)) }>{ it.Name }</a>
|
||||
</div>
|
||||
if it.LastPollError != "" {
|
||||
<button class="v-pill v-pill-error mt-1" 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>
|
||||
</div>
|
||||
if it.LastPollError != "" {
|
||||
<button class="v-pill v-pill-error ml-2" hx-get={ fmt.Sprintf("/items/%d/error", it.ID) } hx-target={ fmt.Sprintf("#item-error-%d", it.ID) } hx-swap="innerHTML">!</button>
|
||||
<div id={ fmt.Sprintf("item-error-%d", it.ID) } class="v-error-text mt-1"></div>
|
||||
}
|
||||
</td>
|
||||
<td class="v-muted">{ it.Category }</td>
|
||||
<td class="font-mono">
|
||||
|
||||
@@ -123,58 +123,71 @@ func itemsBody(d ItemsData) templ.Component {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div><div class=\"flex items-center justify-between mb-6\"><h1 class=\"text-3xl font-semibold\">Items</h1><a class=\"v-btn\" href=\"/items/new\">+ Add Item</a></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div><div class=\"flex items-end justify-between mb-8\"><div><h1 class=\"v-page-title\">Items</h1><p class=\"v-page-sub\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d on the watchlist", len(d.Items)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 102, Col: 76}
|
||||
}
|
||||
_, 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, "</p></div><a class=\"v-btn\" href=\"/items/new\">Add item</a></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(d.Categories) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<form method=\"get\" action=\"/items\" class=\"mb-4 flex items-center gap-2\"><label class=\"v-label mb-0\">Category</label> <select class=\"v-select max-w-xs\" name=\"category\" onchange=\"this.form.submit()\"><option value=\"\">All</option> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<form method=\"get\" action=\"/items\" class=\"mb-4 flex items-center gap-2\"><label class=\"v-label mb-0\">Category</label> <select class=\"v-select max-w-xs\" name=\"category\" onchange=\"this.form.submit()\"><option value=\"\">All</option> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, c := range d.Categories {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(c)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 109, Col: 23}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if c == d.SelectedCategory {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, ">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(c)
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(c)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 109, Col: 67}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 112, Col: 23}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</option>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if c == d.SelectedCategory {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, ">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(c)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 112, Col: 67}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</select></form>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</select></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -185,7 +198,7 @@ func itemsBody(d ItemsData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"v-card p-0 overflow-hidden\"><table class=\"v-table\"><thead><tr><th>Name</th><th>Category</th><th>Target</th><th>Best Price</th><th>Trend</th><th>Last Polled</th><th>Status</th><th></th></tr></thead> <tbody id=\"items-tbody\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"v-card p-0 overflow-hidden\"><table class=\"v-table\"><thead><tr><th>Name</th><th>Category</th><th>Target</th><th>Best Price</th><th>Trend</th><th>Last Polled</th><th>Status</th><th></th></tr></thead> <tbody id=\"items-tbody\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -195,12 +208,12 @@ func itemsBody(d ItemsData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</tbody></table></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</tbody></table></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -224,12 +237,12 @@ func itemsEmpty() templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var4 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var4 == nil {
|
||||
templ_7745c5c3_Var4 = templ.NopComponent
|
||||
templ_7745c5c3_Var5 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var5 == nil {
|
||||
templ_7745c5c3_Var5 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"v-card p-8 flex flex-col md:flex-row items-center gap-6\"><div class=\"v-veola-portrait w-48 shrink-0\"><img src=\"/static/img/veola.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>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"v-card p-8 flex flex-col md:flex-row items-center gap-6\"><div class=\"v-veola-portrait w-48 shrink-0\"><img src=\"/static/img/veola.avif\" alt=\"Veola\"></div><div><h2 class=\"text-xl font-semibold mb-2\">Nothing on the watchlist.</h2><p class=\"v-muted mb-4\">Add an item and Veola will keep an eye on it.</p><a class=\"v-btn\" href=\"/items/new\">Add the first item</a></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -253,544 +266,552 @@ func itemRow(it models.Item, csrf string, history []models.PricePoint) templ.Com
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var5 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var5 == nil {
|
||||
templ_7745c5c3_Var5 = templ.NopComponent
|
||||
templ_7745c5c3_Var6 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var6 == nil {
|
||||
templ_7745c5c3_Var6 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<tr id=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<tr id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("item-row-%d", it.ID))
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, 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: 156, Col: 43}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 159, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
_, 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, 14, "\"><td><div class=\"flex items-center gap-2\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\"><td><div class=\"flex items-center gap-3\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = thumbSm(it.BestPriceImageURL).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"min-w-0\"><div class=\"flex items-center gap-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if isDeal(it) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<img class=\"v-deal-mascot\" src=\"/static/img/veola.avif\" alt=\"\" title=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<img class=\"v-deal-mascot\" src=\"/static/img/veola.avif\" alt=\"\" title=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("Deal! Best %s ≤ target %s", fmtPrice(it.BestPrice, it.BestPriceCurrency), fmtPrice(it.TargetPrice, "USD")))
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, 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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 166, Col: 199}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
_, 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, "\"> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\"> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<a href=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 templ.SafeURL
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results", it.ID)))
|
||||
var templ_7745c5c3_Var9 templ.SafeURL
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results", it.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 162, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 168, Col: 70}
|
||||
}
|
||||
_, 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>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(it.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 168, Col: 82}
|
||||
}
|
||||
_, 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, 21, "</a></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if it.LastPollError != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<button class=\"v-pill v-pill-error ml-2\" hx-get=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/items/%d/error", it.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 165, Col: 91}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" hx-target=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<button class=\"v-pill v-pill-error mt-1\" hx-get=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("#item-error-%d", it.ID))
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/items/%d/error", it.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 165, Col: 142}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 171, Col: 93}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" hx-swap=\"innerHTML\">!</button><div id=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" hx-target=\"")
|
||||
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))
|
||||
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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 171, Col: 144}
|
||||
}
|
||||
_, 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>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" hx-swap=\"innerHTML\">!</button><div id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, 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: 172, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" class=\"v-error-text mt-1\"></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</td><td class=\"v-muted\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div></div></td><td class=\"v-muted\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(it.Category)
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(it.Category)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 169, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 177, 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_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</td><td class=\"font-mono\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</td><td class=\"font-mono\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if it.TargetPrice != nil {
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(it.TargetPrice, "USD"))
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(it.TargetPrice, "USD"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 172, Col: 37}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 180, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<span class=\"v-muted\">—</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<span class=\"v-muted\">—</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</td><td>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</td><td>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if it.BestPrice != nil {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<div class=\"flex items-center gap-1.5\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<div class=\"flex items-center gap-1.5\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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...)
|
||||
var templ_7745c5c3_Var16 = []any{"font-mono text-lg", priceClass(it.BestPrice, it.TargetPrice)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var16...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<span class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var15).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_Var16)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<span class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(it.BestPrice, it.BestPriceCurrency))
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var16).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 180, Col: 129}
|
||||
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_Var17))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</span> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, 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: 188, Col: 129}
|
||||
}
|
||||
_, 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, 33, "</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...)
|
||||
var templ_7745c5c3_Var19 = []any{"v-trend", cls}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var19...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<span class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var18).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_Var19)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<span class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(glyph)
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var19).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 182, Col: 44}
|
||||
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_Var20))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\">")
|
||||
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=\"")
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(glyph)
|
||||
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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 190, Col: 44}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" target=\"_blank\" rel=\"noopener\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(it.BestPriceStore)
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if it.BestPriceURL != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<a class=\"text-xs\" href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 186, Col: 114}
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var22 templ.SafeURL
|
||||
templ_7745c5c3_Var22, 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: 194, Col: 61}
|
||||
}
|
||||
_, 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\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" target=\"_blank\" rel=\"noopener\">")
|
||||
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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 194, Col: 114}
|
||||
}
|
||||
_, 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>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if it.BestPriceStore != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<span class=\"text-xs v-muted\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(it.BestPriceStore)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 196, Col: 54}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<span class=\"v-muted\">not yet</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<span class=\"v-muted\">not yet</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</td><td>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</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...)
|
||||
var templ_7745c5c3_Var25 = []any{"v-sparkline", sparklineTrendClass(history)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var25...)
|
||||
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=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<svg class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var26 string
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(pts)
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var25).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 197, Col: 27}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"></polyline></svg>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<span class=\"v-muted text-xs\">—</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</td><td class=\"v-muted text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if it.LastPolledAt != nil {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<span title=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" 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_Var27 string
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(it.LastPolledAt.Format("2006-01-02 15:04:05"))
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(pts)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 205, Col: 63}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 205, Col: 27}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" 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, 48, "<span class=\"v-muted text-xs\">—</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</td><td class=\"v-muted text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if it.LastPolledAt != nil {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<span title=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var28 string
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(*it.LastPolledAt))
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(it.LastPolledAt.Format("2006-01-02 15:04:05"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 205, Col: 95}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 213, Col: 63}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(*it.LastPolledAt))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 213, Col: 95}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "—")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "—")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</td><td>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</td><td>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if it.Active {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<span class=\"v-pill v-pill-active\">active</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<span class=\"v-pill v-pill-active\">active</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<span class=\"v-pill v-pill-paused\">paused</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<span class=\"v-pill v-pill-paused\">paused</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</td><td class=\"text-right whitespace-nowrap\"><form class=\"inline\" hx-post=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/items/%d/toggle", it.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 218, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" hx-target=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</td><td class=\"text-right whitespace-nowrap\"><form class=\"inline\" hx-post=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("#item-row-%d", it.ID))
|
||||
templ_7745c5c3_Var30, 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: 218, Col: 121}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 226, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" hx-swap=\"outerHTML\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" hx-target=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrf)
|
||||
templ_7745c5c3_Var31, 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: 219, Col: 55}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 226, Col: 121}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\" hx-swap=\"outerHTML\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||
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))
|
||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrf)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 228, Col: 69}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 227, Col: 55}
|
||||
}
|
||||
_, 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=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\"> <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, 61, "Pause")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "Resume")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</button></form><form class=\"inline\" hx-post=\"")
|
||||
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))
|
||||
templ_7745c5c3_Var33, 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: 118}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 236, Col: 69}
|
||||
}
|
||||
_, 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=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\" hx-target=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var34 string
|
||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrf)
|
||||
templ_7745c5c3_Var34, 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: 229, Col: 55}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 236, Col: 118}
|
||||
}
|
||||
_, 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=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" 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_Var35 templ.SafeURL
|
||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/edit", it.ID)))
|
||||
var templ_7745c5c3_Var35 string
|
||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrf)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 235, Col: 92}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 237, Col: 55}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(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=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\"> <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_Var36 string
|
||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/items/%d/delete", it.ID))
|
||||
var templ_7745c5c3_Var36 templ.SafeURL
|
||||
templ_7745c5c3_Var36, 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: 236, Col: 72}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 243, Col: 92}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\" hx-target=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\">Edit</a><form class=\"inline\" hx-post=\"")
|
||||
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))
|
||||
templ_7745c5c3_Var37, 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: 121}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 244, Col: 72}
|
||||
}
|
||||
_, 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=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\" hx-target=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var38 string
|
||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrf)
|
||||
templ_7745c5c3_Var38, 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: 237, Col: 55}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 244, Col: 121}
|
||||
}
|
||||
_, 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>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\" 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_Var39 string
|
||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrf)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 245, Col: 55}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\"> <button class=\"v-btn-ghost text-sm\" type=\"submit\">Delete</button></form></td></tr>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -824,9 +845,9 @@ func Items(d ItemsData) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var39 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var39 == nil {
|
||||
templ_7745c5c3_Var39 = templ.NopComponent
|
||||
templ_7745c5c3_Var40 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var40 == nil {
|
||||
templ_7745c5c3_Var40 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = Layout(d.Page, itemsBody(d)).Render(ctx, templ_7745c5c3_Buffer)
|
||||
@@ -856,9 +877,9 @@ func ItemRow(it models.Item, csrf string, history []models.PricePoint) templ.Com
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var40 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var40 == nil {
|
||||
templ_7745c5c3_Var40 = templ.NopComponent
|
||||
templ_7745c5c3_Var41 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var41 == nil {
|
||||
templ_7745c5c3_Var41 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = itemRow(it, csrf, history).Render(ctx, templ_7745c5c3_Buffer)
|
||||
@@ -886,12 +907,12 @@ func EmptyRow() templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var41 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var41 == nil {
|
||||
templ_7745c5c3_Var41 = templ.NopComponent
|
||||
templ_7745c5c3_Var42 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var42 == nil {
|
||||
templ_7745c5c3_Var42 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<tr></tr>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<tr></tr>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ templ head(title string) {
|
||||
</head>
|
||||
}
|
||||
|
||||
templ Sidebar(active string) {
|
||||
templ Sidebar(active, csrf string) {
|
||||
<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>
|
||||
@@ -41,7 +41,11 @@ templ Sidebar(active string) {
|
||||
<a href="/items" class={ navClass("items", active) }>Items</a>
|
||||
<a href="/results" class={ navClass("results", active) }>Results</a>
|
||||
<a href="/settings" class={ navClass("settings", active) }>Settings</a>
|
||||
<div class="mt-auto px-4 py-4 v-muted text-xs">
|
||||
<form method="post" action="/logout" class="mt-auto">
|
||||
@CSRFInput(csrf)
|
||||
<button type="submit" class="v-side-signout">Sign out</button>
|
||||
</form>
|
||||
<div class="px-4 py-4 v-muted text-xs">
|
||||
Track. Watch. Notice.
|
||||
</div>
|
||||
</nav>
|
||||
@@ -59,7 +63,7 @@ templ Layout(p Page, body templ.Component) {
|
||||
<html lang="en">
|
||||
@head(p.Title)
|
||||
<body class="min-h-screen flex">
|
||||
@Sidebar(p.Active)
|
||||
@Sidebar(p.Active, p.CSRFToken)
|
||||
<main class="flex-1 p-6 max-w-6xl">
|
||||
if p.Flash != "" {
|
||||
<div class="v-flash">{ p.Flash }</div>
|
||||
|
||||
@@ -63,7 +63,7 @@ func head(title string) templ.Component {
|
||||
})
|
||||
}
|
||||
|
||||
func Sidebar(active string) templ.Component {
|
||||
func Sidebar(active, csrf string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
@@ -172,7 +172,15 @@ func Sidebar(active string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\">Settings</a><div class=\"mt-auto px-4 py-4 v-muted text-xs\">Track. Watch. Notice.</div></nav>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\">Settings</a><form method=\"post\" action=\"/logout\" class=\"mt-auto\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = CSRFInput(csrf).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<button type=\"submit\" class=\"v-side-signout\">Sign out</button></form><div class=\"px-4 py-4 v-muted text-xs\">Track. Watch. Notice.</div></nav>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -208,7 +216,7 @@ func Layout(p Page, body templ.Component) templ.Component {
|
||||
templ_7745c5c3_Var12 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<!doctype html><html lang=\"en\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<!doctype html><html lang=\"en\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -216,52 +224,52 @@ func Layout(p Page, body templ.Component) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<body class=\"min-h-screen flex\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<body class=\"min-h-screen flex\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Sidebar(p.Active).Render(ctx, templ_7745c5c3_Buffer)
|
||||
templ_7745c5c3_Err = Sidebar(p.Active, p.CSRFToken).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<main class=\"flex-1 p-6 max-w-6xl\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<main class=\"flex-1 p-6 max-w-6xl\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if p.Flash != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div class=\"v-flash\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<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(p.Flash)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 65, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 69, Col: 35}
|
||||
}
|
||||
_, 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, 16, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if p.FlashError != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"v-flash-error\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<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(p.FlashError)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 68, Col: 46}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 72, Col: 46}
|
||||
}
|
||||
_, 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, 18, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -270,7 +278,7 @@ func Layout(p Page, body templ.Component) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</main></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</main></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -300,7 +308,7 @@ func Bare(p Page, body templ.Component) templ.Component {
|
||||
templ_7745c5c3_Var15 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<!doctype html><html lang=\"en\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<!doctype html><html lang=\"en\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -308,7 +316,7 @@ func Bare(p Page, body templ.Component) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<body class=\"min-h-screen\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<body class=\"min-h-screen\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -316,7 +324,7 @@ func Bare(p Page, body templ.Component) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -346,20 +354,20 @@ func CSRFInput(token string) templ.Component {
|
||||
templ_7745c5c3_Var16 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(token)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 89, Col: 53}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 93, Col: 53}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -85,9 +85,9 @@ templ itemResultsBody(d ItemResultsData) {
|
||||
@endingSoonStrip(d.EndingSoon)
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-semibold">{ d.Item.Name }</h1>
|
||||
<h1 class="v-page-title">{ d.Item.Name }</h1>
|
||||
if d.Item.Category != "" {
|
||||
<div class="v-muted">{ d.Item.Category }</div>
|
||||
<div class="v-page-sub">{ d.Item.Category }</div>
|
||||
}
|
||||
</div>
|
||||
<div class="text-right">
|
||||
@@ -121,7 +121,7 @@ templ itemResultsBody(d ItemResultsData) {
|
||||
</div>
|
||||
|
||||
<div class="v-card p-5 mb-6">
|
||||
<h2 class="font-semibold mb-3">Price History</h2>
|
||||
<h2 class="v-section-title mb-3">Price History</h2>
|
||||
if len(d.History) < 2 {
|
||||
<div class="v-muted">Not enough history yet.</div>
|
||||
} else {
|
||||
@@ -171,11 +171,7 @@ templ ItemResultsTable(d ItemResultsData) {
|
||||
<tbody>
|
||||
for _, r := range d.Results {
|
||||
<tr>
|
||||
<td>
|
||||
if r.ImageURL != "" {
|
||||
<img src={ r.ImageURL } alt="" class="w-10 h-10 object-cover rounded"/>
|
||||
}
|
||||
</td>
|
||||
<td>@thumbSm(r.ImageURL)</td>
|
||||
<td>
|
||||
if r.URL != "" {
|
||||
<a href={ templ.SafeURL(r.URL) } target="_blank" rel="noopener">{ r.Title }</a>
|
||||
@@ -239,7 +235,8 @@ templ ItemResults(d ItemResultsData) {
|
||||
|
||||
templ globalResultsBody(d GlobalResultsData) {
|
||||
<div>
|
||||
<h1 class="text-3xl font-semibold mb-6">All Results</h1>
|
||||
<h1 class="v-page-title mb-1">All results</h1>
|
||||
<p class="v-page-sub mb-6">Every listing Veola has found</p>
|
||||
@endingSoonStrip(d.EndingSoon)
|
||||
<form method="get" action="/results" class="flex items-end gap-3 mb-4">
|
||||
<div>
|
||||
@@ -264,11 +261,12 @@ templ globalResultsBody(d GlobalResultsData) {
|
||||
<div class="v-card p-0 overflow-hidden">
|
||||
<table class="v-table">
|
||||
<thead>
|
||||
<tr><th>Item</th><th>Title</th><th>Price</th><th>Store</th><th>Ends</th><th>Found</th><th>Alert</th></tr>
|
||||
<tr><th></th><th>Item</th><th>Title</th><th>Price</th><th>Store</th><th>Ends</th><th>Found</th><th>Alert</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
for _, r := range d.Results {
|
||||
<tr>
|
||||
<td>@thumbSm(r.ImageURL)</td>
|
||||
<td><a href={ templ.SafeURL(fmt.Sprintf("/items/%d/results", r.ItemID)) }>{ r.ItemName }</a></td>
|
||||
<td>
|
||||
if r.URL != "" {
|
||||
|
||||
@@ -248,14 +248,14 @@ func itemResultsBody(d ItemResultsData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"flex items-start justify-between mb-4\"><div><h1 class=\"text-3xl font-semibold\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"flex items-start justify-between mb-4\"><div><h1 class=\"v-page-title\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 88, Col: 52}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 88, Col: 42}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -266,14 +266,14 @@ func itemResultsBody(d ItemResultsData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.Item.Category != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div class=\"v-muted\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div class=\"v-page-sub\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.Category)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 90, Col: 43}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 90, Col: 46}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -411,7 +411,7 @@ func itemResultsBody(d ItemResultsData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\"> <input type=\"hidden\" name=\"from\" value=\"results\"> <span class=\"v-spinner htmx-indicator\"></span> <span class=\"v-muted text-sm htmx-indicator\">Running...</span> <button class=\"v-btn\" type=\"submit\">Run Now</button></form></div></div><div class=\"v-card p-5 mb-6\"><h2 class=\"font-semibold mb-3\">Price History</h2>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\"> <input type=\"hidden\" name=\"from\" value=\"results\"> <span class=\"v-spinner htmx-indicator\"></span> <span class=\"v-muted text-sm htmx-indicator\">Running...</span> <button class=\"v-btn\" type=\"submit\">Run Now</button></form></div></div><div class=\"v-card p-5 mb-6\"><h2 class=\"v-section-title mb-3\">Price History</h2>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -556,137 +556,143 @@ func ItemResultsTable(d ItemResultsData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if r.ImageURL != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<img src=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var26 string
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ImageURL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 176, Col: 30}
|
||||
}
|
||||
_, 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, 46, "\" alt=\"\" class=\"w-10 h-10 object-cover rounded\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = thumbSm(r.ImageURL).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</td><td>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</td><td>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if r.URL != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<a href=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var27 templ.SafeURL
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.URL))
|
||||
var templ_7745c5c3_Var26 templ.SafeURL
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.URL))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 181, Col: 39}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 177, Col: 39}
|
||||
}
|
||||
_, 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, 47, "\" target=\"_blank\" rel=\"noopener\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var27 string
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 177, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\" target=\"_blank\" rel=\"noopener\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
var templ_7745c5c3_Var28 string
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 181, Col: 82}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 179, Col: 18}
|
||||
}
|
||||
_, 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, 50, "</a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 183, Col: 18}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, " ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if r.MatchedQuery != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<div class=\"v-muted text-xs\">via \"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<div class=\"v-muted text-xs\">via \"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(r.MatchedQuery)
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(r.MatchedQuery)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 186, Col: 59}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 182, Col: 59}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\"</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\"</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</td>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</td>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var31 = []any{"font-mono", priceClass(r.Price, d.Item.TargetPrice)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var31...)
|
||||
var templ_7745c5c3_Var30 = []any{"font-mono", priceClass(r.Price, d.Item.TargetPrice)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var30...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<td class=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<td class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var30).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var32 string
|
||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var31).String())
|
||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(r.Price, r.Currency))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 185, Col: 105}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</td><td class=\"v-muted\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var33 string
|
||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(r.Price, r.Currency))
|
||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(r.Source)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 189, Col: 105}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 186, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</td><td class=\"v-muted\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</td><td>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = endsInCell(r.EndsAt).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</td><td class=\"v-muted text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var34 string
|
||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(r.Source)
|
||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(r.FoundAt))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 190, Col: 37}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 188, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -696,103 +702,82 @@ func ItemResultsTable(d ItemResultsData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = endsInCell(r.EndsAt).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</td><td class=\"v-muted text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var35 string
|
||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(r.FoundAt))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 192, Col: 57}
|
||||
}
|
||||
_, 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, 60, "</td><td>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if r.Alerted {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<span class=\"v-pill v-pill-active\">sent</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<span class=\"v-pill v-pill-active\">sent</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</td></tr>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</td></tr>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</tbody></table></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</tbody></table></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.TotalPages > 1 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<div class=\"flex gap-2 justify-center my-4\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<div class=\"flex gap-2 justify-center my-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for i := 1; i <= d.TotalPages; i++ {
|
||||
var templ_7745c5c3_Var36 = []any{pageClass(i, d.Page_)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var36...)
|
||||
var templ_7745c5c3_Var35 = []any{pageClass(i, d.Page_)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var35...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<a class=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<a class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var37 string
|
||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var36).String())
|
||||
var templ_7745c5c3_Var36 string
|
||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var35).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
|
||||
_, 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, "\" href=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\" href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var38 templ.SafeURL
|
||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results?page=%d&order=%s", d.Item.ID, i, d.Order)))
|
||||
var templ_7745c5c3_Var37 templ.SafeURL
|
||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results?page=%d&order=%s", d.Item.ID, i, d.Order)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 206, Col: 134}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 202, Col: 134}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
|
||||
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
|
||||
}
|
||||
var templ_7745c5c3_Var38 string
|
||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", i))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 202, Col: 159}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var39 string
|
||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", i))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 206, Col: 159}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</a>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -839,9 +824,9 @@ func ItemResults(d ItemResultsData) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var40 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var40 == nil {
|
||||
templ_7745c5c3_Var40 = templ.NopComponent
|
||||
templ_7745c5c3_Var39 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var39 == nil {
|
||||
templ_7745c5c3_Var39 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = Layout(d.Page, itemResultsBody(d)).Render(ctx, templ_7745c5c3_Buffer)
|
||||
@@ -868,12 +853,12 @@ func globalResultsBody(d GlobalResultsData) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var41 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var41 == nil {
|
||||
templ_7745c5c3_Var41 = templ.NopComponent
|
||||
templ_7745c5c3_Var40 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var40 == nil {
|
||||
templ_7745c5c3_Var40 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<div><h1 class=\"text-3xl font-semibold mb-6\">All Results</h1>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<div><h1 class=\"v-page-title mb-1\">All results</h1><p class=\"v-page-sub mb-6\">Every listing Veola has found</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -881,205 +866,213 @@ func globalResultsBody(d GlobalResultsData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<form method=\"get\" action=\"/results\" class=\"flex items-end gap-3 mb-4\"><div><label class=\"v-label\">Item</label> <select class=\"v-select\" name=\"item_id\"><option value=\"0\">All</option> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<form method=\"get\" action=\"/results\" class=\"flex items-end gap-3 mb-4\"><div><label class=\"v-label\">Item</label> <select class=\"v-select\" name=\"item_id\"><option value=\"0\">All</option> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, it := range d.Items {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<option value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var42 string
|
||||
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", it.ID))
|
||||
var templ_7745c5c3_Var41 string
|
||||
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", it.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 250, Col: 46}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 247, Col: 46}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.ItemID == it.ID {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, ">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, ">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var43 string
|
||||
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(it.Name)
|
||||
var templ_7745c5c3_Var42 string
|
||||
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(it.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 250, Col: 90}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 247, Col: 90}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</option>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</select></div><div><label class=\"v-label\">From</label> <input class=\"v-input\" type=\"date\" name=\"from\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</select></div><div><label class=\"v-label\">From</label> <input class=\"v-input\" type=\"date\" name=\"from\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var43 string
|
||||
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.From)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 253, Col: 65}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "\"></div><div><label class=\"v-label\">To</label> <input class=\"v-input\" type=\"date\" name=\"to\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var44 string
|
||||
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.From)
|
||||
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.To)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 256, Col: 65}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 257, Col: 61}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "\"></div><div><label class=\"v-label\">To</label> <input class=\"v-input\" type=\"date\" name=\"to\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var45 string
|
||||
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.To)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 260, Col: 61}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\"></div><button class=\"v-btn\" type=\"submit\">Filter</button></form><div class=\"v-card p-0 overflow-hidden\"><table class=\"v-table\"><thead><tr><th>Item</th><th>Title</th><th>Price</th><th>Store</th><th>Ends</th><th>Found</th><th>Alert</th></tr></thead> <tbody>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\"></div><button class=\"v-btn\" type=\"submit\">Filter</button></form><div class=\"v-card p-0 overflow-hidden\"><table class=\"v-table\"><thead><tr><th></th><th>Item</th><th>Title</th><th>Price</th><th>Store</th><th>Ends</th><th>Found</th><th>Alert</th></tr></thead> <tbody>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, r := range d.Results {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<tr><td><a href=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<tr><td>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var46 templ.SafeURL
|
||||
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results", r.ItemID)))
|
||||
templ_7745c5c3_Err = thumbSm(r.ImageURL).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 272, Col: 78}
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</td><td><a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var45 templ.SafeURL
|
||||
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results", r.ItemID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 270, Col: 78}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var46 string
|
||||
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(r.ItemName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 270, Col: 93}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var47 string
|
||||
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(r.ItemName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 272, Col: 93}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</a></td><td>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</a></td><td>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if r.URL != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<a href=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var48 templ.SafeURL
|
||||
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.URL))
|
||||
var templ_7745c5c3_Var47 templ.SafeURL
|
||||
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.URL))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 275, Col: 39}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 273, Col: 39}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" target=\"_blank\" rel=\"noopener\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var48 string
|
||||
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 273, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\" target=\"_blank\" rel=\"noopener\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
var templ_7745c5c3_Var49 string
|
||||
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 275, Col: 82}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 275, Col: 18}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
var templ_7745c5c3_Var50 string
|
||||
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 277, Col: 18}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, " ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, " ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if r.MatchedQuery != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<div class=\"v-muted text-xs\">via \"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<div class=\"v-muted text-xs\">via \"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var51 string
|
||||
templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(r.MatchedQuery)
|
||||
var templ_7745c5c3_Var50 string
|
||||
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(r.MatchedQuery)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 280, Col: 59}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 278, Col: 59}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\"</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\"</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "</td><td class=\"font-mono\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</td><td class=\"font-mono\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var51 string
|
||||
templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(r.Price, r.Currency))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 281, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "</td><td class=\"v-muted\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var52 string
|
||||
templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(r.Price, r.Currency))
|
||||
templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(r.Source)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 283, Col: 60}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 282, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</td><td class=\"v-muted\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var53 string
|
||||
templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(r.Source)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 284, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</td><td>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</td><td>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -1087,35 +1080,35 @@ func globalResultsBody(d GlobalResultsData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</td><td class=\"v-muted text-sm\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</td><td class=\"v-muted text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var54 string
|
||||
templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(r.FoundAt))
|
||||
var templ_7745c5c3_Var53 string
|
||||
templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(r.FoundAt))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 286, Col: 57}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 284, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</td><td>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</td><td>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if r.Alerted {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<span class=\"v-pill v-pill-active\">sent</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<span class=\"v-pill v-pill-active\">sent</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</td></tr>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "</td></tr>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "</tbody></table></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</tbody></table></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -1139,9 +1132,9 @@ func GlobalResults(d GlobalResultsData) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var55 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var55 == nil {
|
||||
templ_7745c5c3_Var55 = templ.NopComponent
|
||||
templ_7745c5c3_Var54 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var54 == nil {
|
||||
templ_7745c5c3_Var54 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = Layout(d.Page, globalResultsBody(d)).Render(ctx, templ_7745c5c3_Buffer)
|
||||
|
||||
@@ -8,22 +8,34 @@ import (
|
||||
|
||||
type SettingsData struct {
|
||||
Page
|
||||
Values map[string]string
|
||||
// CredentialStatus maps each secret settings key to a human-readable
|
||||
// status ("Saved in settings", "Set in config.toml", "Not set"). Secret
|
||||
// values are never rendered into the form itself.
|
||||
CredentialStatus map[string]string
|
||||
IsAdmin bool
|
||||
Values map[string]string
|
||||
IsAdmin bool
|
||||
Users []models.User
|
||||
TestNtfyOK string
|
||||
TestApifyOK string
|
||||
TestEbayOK string
|
||||
TestResendOK string
|
||||
EbayUsedToday int
|
||||
EbayDailyLimit int
|
||||
ApifyToday int
|
||||
ApifyMonth int
|
||||
ApifyCostPerCall float64
|
||||
MonthlyBudget float64
|
||||
PasswordMsg string
|
||||
PasswordError string
|
||||
UserMsg string
|
||||
UserError string
|
||||
EmailMsg string
|
||||
EmailError string
|
||||
// ForwardAuthMode is true when the deployment delegates identity to
|
||||
// Authentik (forward-auth). Email is then managed centrally in Authentik
|
||||
// and read-only in Veola for every user.
|
||||
ForwardAuthMode bool
|
||||
}
|
||||
|
||||
// ApifyMonthCost is the estimated month-to-date Apify spend.
|
||||
func (d SettingsData) ApifyMonthCost() float64 {
|
||||
return float64(d.ApifyMonth) * d.ApifyCostPerCall
|
||||
}
|
||||
|
||||
// EbayLimitReached reports whether eBay polling is currently halted because
|
||||
@@ -32,39 +44,20 @@ func (d SettingsData) EbayLimitReached() bool {
|
||||
return d.EbayDailyLimit > 0 && d.EbayUsedToday >= d.EbayDailyLimit
|
||||
}
|
||||
|
||||
// credStatus renders the "Saved in settings / Set in config.toml / Not set"
|
||||
// indicator for a secret field without ever printing the secret itself.
|
||||
templ credStatus(d SettingsData, key string) {
|
||||
if s := d.CredentialStatus[key]; s != "" {
|
||||
<div class="v-muted text-xs mt-1">
|
||||
Status: { s }
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ settingsBody(d SettingsData) {
|
||||
<div class="space-y-8 max-w-3xl">
|
||||
<h1 class="text-3xl font-semibold">Settings</h1>
|
||||
<div>
|
||||
<h1 class="v-page-title">Settings</h1>
|
||||
<p class="v-page-sub">Credentials, budget, notifications, and users</p>
|
||||
</div>
|
||||
|
||||
<section class="v-card p-6">
|
||||
<h2 class="font-semibold mb-4">Apify, eBay and Ntfy</h2>
|
||||
<h2 class="v-section-title mb-4">Apify, eBay and Ntfy</h2>
|
||||
<div class="v-muted text-sm mb-4">
|
||||
API credentials (Apify, eBay, ntfy, Resend) are managed in config.toml on the server, not here. Use the Test buttons to verify them.
|
||||
</div>
|
||||
<form method="post" action="/settings" class="space-y-4">
|
||||
@CSRFInput(d.CSRFToken)
|
||||
<div>
|
||||
<label class="v-label">Apify API Key</label>
|
||||
<input class="v-input font-mono" type="password" name="apify_api_key" 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>
|
||||
<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)"/>
|
||||
@@ -88,11 +81,6 @@ templ settingsBody(d SettingsData) {
|
||||
<label class="v-label">Ntfy Default Topic</label>
|
||||
<input class="v-input" name="ntfy_default_topic" value={ d.Values["ntfy_default_topic"] }/>
|
||||
</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)"/>
|
||||
@credStatus(d, "ntfy_token")
|
||||
</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={ d.Values["global_poll_interval_minutes"] }/>
|
||||
@@ -101,6 +89,16 @@ templ settingsBody(d SettingsData) {
|
||||
<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={ d.Values["match_confidence_threshold"] }/>
|
||||
</div>
|
||||
<div class="border-t border-white/10 pt-4">
|
||||
<label class="v-label">Estimated Apify Cost per Call (USD)</label>
|
||||
<input class="v-input font-mono" name="apify_cost_per_call" type="number" min="0" step="0.001" value={ d.Values["apify_cost_per_call"] }/>
|
||||
<div class="v-muted text-xs mt-1">Apify does not report exact spend; this estimate drives the budget figures shown to everyone.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="v-label">Monthly Budget (USD)</label>
|
||||
<input class="v-input font-mono" name="monthly_budget_usd" type="number" min="0" step="1" value={ d.Values["monthly_budget_usd"] }/>
|
||||
<div class="v-muted text-xs mt-1">0 hides the budget bar. When set, the dashboard shows month-to-date spend against it.</div>
|
||||
</div>
|
||||
if !d.IsAdmin {
|
||||
<div class="v-muted text-sm">Read-only for non-admin users.</div>
|
||||
} else {
|
||||
@@ -109,6 +107,7 @@ templ settingsBody(d SettingsData) {
|
||||
<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>
|
||||
<button class="v-btn-ghost" type="submit" formaction="/settings/test-resend">Test Resend</button>
|
||||
</div>
|
||||
}
|
||||
</form>
|
||||
@@ -121,10 +120,22 @@ templ settingsBody(d SettingsData) {
|
||||
if d.TestEbayOK != "" {
|
||||
<div class="v-flash mt-3">{ d.TestEbayOK }</div>
|
||||
}
|
||||
if d.TestResendOK != "" {
|
||||
<div class="v-flash mt-3">{ d.TestResendOK }</div>
|
||||
}
|
||||
<div class="text-sm mt-4 border-t border-white/10 pt-4">
|
||||
<span class="v-muted">Apify calls this month:</span>
|
||||
<span class="font-mono">{ fmt.Sprintf("%d", d.ApifyMonth) }</span>
|
||||
if d.ApifyCostPerCall > 0 {
|
||||
<span class="v-muted ml-1">{ fmt.Sprintf("(~$%.2f est.)", d.ApifyMonthCost()) }</span>
|
||||
}
|
||||
<span class="v-muted ml-3">today:</span>
|
||||
<span class="font-mono">{ fmt.Sprintf("%d", d.ApifyToday) }</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="v-card p-6">
|
||||
<h2 class="font-semibold mb-4">Change Password</h2>
|
||||
<h2 class="v-section-title mb-4">Change Password</h2>
|
||||
if d.PasswordError != "" {
|
||||
<div class="v-flash-error">{ d.PasswordError }</div>
|
||||
}
|
||||
@@ -149,9 +160,41 @@ templ settingsBody(d SettingsData) {
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="v-card p-6">
|
||||
<h2 class="v-section-title mb-4">Email Notifications</h2>
|
||||
if d.EmailError != "" {
|
||||
<div class="v-flash-error">{ d.EmailError }</div>
|
||||
}
|
||||
if d.EmailMsg != "" {
|
||||
<div class="v-flash">{ d.EmailMsg }</div>
|
||||
}
|
||||
<form method="post" action="/settings/email" class="space-y-4">
|
||||
@CSRFInput(d.CSRFToken)
|
||||
<div>
|
||||
<label class="v-label">Notification Email</label>
|
||||
if emailManagedExternally(d) {
|
||||
<input class="v-input" type="email" value={ currentEmail(d) } disabled/>
|
||||
<div class="v-muted text-xs mt-1">Managed by Authentik. Change your email in your Authentik profile; it syncs here on next sign-in.</div>
|
||||
} else {
|
||||
<input class="v-input" type="email" name="email" value={ currentEmail(d) } placeholder="you@example.com"/>
|
||||
<div class="v-muted text-xs mt-1">Where deal alerts and the weekly digest are sent. Requires Resend to be configured by an admin.</div>
|
||||
}
|
||||
</div>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" name="email_deal_alerts" value="1" checked?={ currentDealAlerts(d) }/>
|
||||
<span>Email me deal alerts</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" name="email_weekly_digest" value="1" checked?={ currentWeeklyDigest(d) }/>
|
||||
<span>Email me the weekly digest (Mondays)</span>
|
||||
</label>
|
||||
<button class="v-btn" type="submit">Save Preferences</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
if d.IsAdmin {
|
||||
<section class="v-card p-6">
|
||||
<h2 class="font-semibold mb-4">Users</h2>
|
||||
<h2 class="v-section-title mb-4">Users</h2>
|
||||
if d.UserError != "" {
|
||||
<div class="v-flash-error">{ d.UserError }</div>
|
||||
}
|
||||
@@ -205,6 +248,31 @@ templ settingsBody(d SettingsData) {
|
||||
</div>
|
||||
}
|
||||
|
||||
// currentEmail and the two opt-in accessors read the signed-in user's saved
|
||||
// email preferences off Page.CurrentUser, guarding the nil case.
|
||||
func currentEmail(d SettingsData) string {
|
||||
if d.CurrentUser == nil {
|
||||
return ""
|
||||
}
|
||||
return d.CurrentUser.Email
|
||||
}
|
||||
|
||||
func currentDealAlerts(d SettingsData) bool {
|
||||
return d.CurrentUser != nil && d.CurrentUser.EmailDealAlerts
|
||||
}
|
||||
|
||||
func currentWeeklyDigest(d SettingsData) bool {
|
||||
return d.CurrentUser != nil && d.CurrentUser.EmailWeeklyDigest
|
||||
}
|
||||
|
||||
// emailManagedExternally reports whether email is owned by Authentik and so is
|
||||
// read-only in Veola. True for any user when the deployment runs in forward-auth
|
||||
// mode (email is centralized in Authentik), and for an individual forward row
|
||||
// regardless of mode (the address is its IdP match key).
|
||||
func emailManagedExternally(d SettingsData) bool {
|
||||
return d.ForwardAuthMode || (d.CurrentUser != nil && d.CurrentUser.AuthSource == "forward")
|
||||
}
|
||||
|
||||
templ Settings(d SettingsData) {
|
||||
@Layout(d.Page, settingsBody(d))
|
||||
}
|
||||
|
||||
@@ -16,22 +16,34 @@ import (
|
||||
|
||||
type SettingsData struct {
|
||||
Page
|
||||
Values map[string]string
|
||||
// CredentialStatus maps each secret settings key to a human-readable
|
||||
// status ("Saved in settings", "Set in config.toml", "Not set"). Secret
|
||||
// values are never rendered into the form itself.
|
||||
CredentialStatus map[string]string
|
||||
Values map[string]string
|
||||
IsAdmin bool
|
||||
Users []models.User
|
||||
TestNtfyOK string
|
||||
TestApifyOK string
|
||||
TestEbayOK string
|
||||
TestResendOK string
|
||||
EbayUsedToday int
|
||||
EbayDailyLimit int
|
||||
ApifyToday int
|
||||
ApifyMonth int
|
||||
ApifyCostPerCall float64
|
||||
MonthlyBudget float64
|
||||
PasswordMsg string
|
||||
PasswordError string
|
||||
UserMsg string
|
||||
UserError string
|
||||
EmailMsg string
|
||||
EmailError string
|
||||
// ForwardAuthMode is true when the deployment delegates identity to
|
||||
// Authentik (forward-auth). Email is then managed centrally in Authentik
|
||||
// and read-only in Veola for every user.
|
||||
ForwardAuthMode bool
|
||||
}
|
||||
|
||||
// ApifyMonthCost is the estimated month-to-date Apify spend.
|
||||
func (d SettingsData) ApifyMonthCost() float64 {
|
||||
return float64(d.ApifyMonth) * d.ApifyCostPerCall
|
||||
}
|
||||
|
||||
// EbayLimitReached reports whether eBay polling is currently halted because
|
||||
@@ -40,9 +52,7 @@ 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 {
|
||||
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 {
|
||||
@@ -63,51 +73,7 @@ func credStatus(d SettingsData, key string) templ.Component {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
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\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"space-y-8 max-w-3xl\"><div><h1 class=\"v-page-title\">Settings</h1><p class=\"v-page-sub\">Credentials, budget, notifications, and users</p></div><section class=\"v-card p-6\"><h2 class=\"v-section-title mb-4\">Apify, eBay and Ntfy</h2><div class=\"v-muted text-sm mb-4\">API credentials (Apify, eBay, ntfy, Resend) are managed in config.toml on the server, not here. Use the Test buttons to verify them.</div><form method=\"post\" action=\"/settings\" class=\"space-y-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -115,199 +81,231 @@ func settingsBody(d SettingsData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div><label class=\"v-label\">eBay Daily Call Limit</label> <input class=\"v-input font-mono\" name=\"ebay_daily_call_limit\" type=\"number\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = credStatus(d, "apify_api_key").Render(ctx, templ_7745c5c3_Buffer)
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ebay_daily_call_limit"])
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 63, Col: 122}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div><div class=\"border-t border-white/10 pt-4\"><label class=\"v-label\">eBay App ID (Client ID)</label> <input class=\"v-input font-mono\" type=\"password\" name=\"ebay_client_id\" autocomplete=\"off\" placeholder=\"used for eBay marketplaces instead of Apify\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = credStatus(d, "ebay_client_id").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div><div><label class=\"v-label\">eBay Cert ID (Client Secret)</label> <input class=\"v-input font-mono\" type=\"password\" name=\"ebay_client_secret\" autocomplete=\"off\" placeholder=\"leave blank to keep current value\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = credStatus(d, "ebay_client_secret").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div><div><label class=\"v-label\">eBay Daily Call Limit</label> <input class=\"v-input font-mono\" name=\"ebay_daily_call_limit\" type=\"number\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ebay_daily_call_limit"])
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 70, Col: 122}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" placeholder=\"5000 (blank uses config default)\"></div><div class=\"text-sm\"><span class=\"v-muted\">eBay API calls today:</span> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" placeholder=\"5000 (blank uses config default)\"></div><div class=\"text-sm\"><span class=\"v-muted\">eBay API calls today:</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.EbayDailyLimit > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<span class=\"font-mono\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<span class=\"font-mono\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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))
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, 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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 68, Col: 89}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</span> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<span class=\"font-mono\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<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))
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, 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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 70, Col: 77}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</span> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if d.EbayLimitReached() {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<span class=\"v-flash-error inline-block ml-2\">Limit reached. eBay polling halted until the next reset (midnight US Pacific).</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<span class=\"v-flash-error inline-block ml-2\">Limit reached. eBay polling halted until the next reset (midnight US Pacific).</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div><div class=\"border-t border-white/10 pt-4\"><label class=\"v-label\">Ntfy Base URL</label> <input class=\"v-input\" name=\"ntfy_base_url\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</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_Var5 string
|
||||
templ_7745c5c3_Var5, 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: 78, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\"></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_Var6 string
|
||||
templ_7745c5c3_Var6, 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: 82, Col: 92}
|
||||
}
|
||||
_, 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, 11, "\"></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_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_base_url"])
|
||||
templ_7745c5c3_Var7, 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: 85, Col: 82}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 86, Col: 136}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\"></div><div><label class=\"v-label\">Ntfy Default Topic</label> <input class=\"v-input\" name=\"ntfy_default_topic\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"></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_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_default_topic"])
|
||||
templ_7745c5c3_Var8, 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: 89, Col: 92}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 90, Col: 160}
|
||||
}
|
||||
_, 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=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"></div><div class=\"border-t border-white/10 pt-4\"><label class=\"v-label\">Estimated Apify Cost per Call (USD)</label> <input class=\"v-input font-mono\" name=\"apify_cost_per_call\" type=\"number\" min=\"0\" step=\"0.001\" 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"])
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["apify_cost_per_call"])
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 98, Col: 136}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 94, Col: 139}
|
||||
}
|
||||
_, 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=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\"><div class=\"v-muted text-xs mt-1\">Apify does not report exact spend; this estimate drives the budget figures shown to everyone.</div></div><div><label class=\"v-label\">Monthly Budget (USD)</label> <input class=\"v-input font-mono\" name=\"monthly_budget_usd\" type=\"number\" min=\"0\" step=\"1\" 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"])
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["monthly_budget_usd"])
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 102, Col: 160}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 99, Col: 133}
|
||||
}
|
||||
_, 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>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\"><div class=\"v-muted text-xs mt-1\">0 hides the budget bar. When set, the dashboard shows month-to-date spend against it.</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !d.IsAdmin {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"v-muted text-sm\">Read-only for non-admin users.</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"v-muted text-sm\">Read-only for non-admin users.</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<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> <button class=\"v-btn-ghost\" type=\"submit\" formaction=\"/settings/test-resend\">Test Resend</button></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</form>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.TestNtfyOK != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"v-flash mt-3\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"v-flash mt-3\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestNtfyOK)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 116, Col: 44}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 115, Col: 44}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if d.TestApifyOK != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<div class=\"v-flash mt-3\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_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: 118, 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, 22, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if d.TestEbayOK != "" {
|
||||
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_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: 121, 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, 24, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if d.TestApifyOK != "" {
|
||||
if d.TestResendOK != "" {
|
||||
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)
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestResendOK)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 119, Col: 45}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 124, Col: 46}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -316,68 +314,98 @@ func settingsBody(d SettingsData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if d.TestEbayOK != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<div class=\"v-flash mt-3\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<div class=\"text-sm mt-4 border-t border-white/10 pt-4\"><span class=\"v-muted\">Apify calls this month:</span> <span class=\"font-mono\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.ApifyMonth))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 128, Col: 61}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.ApifyCostPerCall > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<span class=\"v-muted ml-1\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestEbayOK)
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("(~$%.2f est.)", d.ApifyMonthCost()))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 122, Col: 44}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 130, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</span> ")
|
||||
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>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<span class=\"v-muted ml-3\">today:</span> <span class=\"font-mono\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.ApifyToday))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 133, 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, 32, "</span></div></section><section class=\"v-card p-6\"><h2 class=\"v-section-title 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\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<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)
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, 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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 140, Col: 48}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
_, 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, 31, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</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\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<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)
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, 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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 143, Col: 40}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</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\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<form method=\"post\" action=\"/settings/password\" class=\"space-y-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -385,173 +413,284 @@ func settingsBody(d SettingsData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<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><section class=\"v-card p-6\"><h2 class=\"v-section-title mb-4\">Email Notifications</h2>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.EmailError != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<div class=\"v-flash-error\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(d.EmailError)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 166, Col: 45}
|
||||
}
|
||||
_, 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, 40, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if d.EmailMsg != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<div class=\"v-flash\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(d.EmailMsg)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 169, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<form method=\"post\" action=\"/settings/email\" class=\"space-y-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = CSRFInput(d.CSRFToken).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<div><label class=\"v-label\">Notification Email</label> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if emailManagedExternally(d) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<input class=\"v-input\" type=\"email\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentEmail(d))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 176, Col: 65}
|
||||
}
|
||||
_, 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, 46, "\" disabled><div class=\"v-muted text-xs mt-1\">Managed by Authentik. Change your email in your Authentik profile; it syncs here on next sign-in.</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<input class=\"v-input\" type=\"email\" name=\"email\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentEmail(d))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 179, Col: 78}
|
||||
}
|
||||
_, 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, 48, "\" placeholder=\"you@example.com\"><div class=\"v-muted text-xs mt-1\">Where deal alerts and the weekly digest are sent. Requires Resend to be configured by an admin.</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</div><label class=\"flex items-center gap-2\"><input type=\"checkbox\" name=\"email_deal_alerts\" value=\"1\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if currentDealAlerts(d) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " checked")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "> <span>Email me deal alerts</span></label> <label class=\"flex items-center gap-2\"><input type=\"checkbox\" name=\"email_weekly_digest\" value=\"1\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if currentWeeklyDigest(d) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " checked")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "> <span>Email me the weekly digest (Mondays)</span></label> <button class=\"v-btn\" type=\"submit\">Save Preferences</button></form></section>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.IsAdmin {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Users</h2>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<section class=\"v-card p-6\"><h2 class=\"v-section-title mb-4\">Users</h2>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.UserError != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<div class=\"v-flash-error\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<div class=\"v-flash-error\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserError)
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserError)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 156, Col: 45}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 199, 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_Var24))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if d.UserMsg != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<div class=\"v-flash\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<div class=\"v-flash\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserMsg)
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, 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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 202, 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_Var25))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</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>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<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>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<tr><td>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
|
||||
var templ_7745c5c3_Var26 string
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 166, Col: 24}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 209, Col: 24}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
_, 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, 43, "</td><td class=\"v-muted\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</td><td class=\"v-muted\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(string(u.Role))
|
||||
var templ_7745c5c3_Var27 string
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(string(u.Role))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 167, Col: 44}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 210, 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_Var27))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</td><td class=\"v-muted text-sm\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</td><td class=\"v-muted text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(u.CreatedAt.Format("2006-01-02"))
|
||||
var templ_7745c5c3_Var28 string
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(u.CreatedAt.Format("2006-01-02"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 168, Col: 70}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 211, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</td><td class=\"text-right\"><form class=\"inline\" method=\"post\" action=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</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)))
|
||||
var templ_7745c5c3_Var29 templ.SafeURL
|
||||
templ_7745c5c3_Var29, 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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 213, Col: 113}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
||||
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=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\"><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)
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, 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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 214, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\"> <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)))
|
||||
var templ_7745c5c3_Var31 templ.SafeURL
|
||||
templ_7745c5c3_Var31, 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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 218, Col: 105}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
||||
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=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\" 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)
|
||||
var templ_7745c5c3_Var32 string
|
||||
templ_7745c5c3_Var32, 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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 219, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||
_, 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, 49, "\"> <button class=\"v-btn-ghost\" type=\"submit\">Remove</button></form></td></tr>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\"> <button class=\"v-btn-ghost\" type=\"submit\">Remove</button></form></td></tr>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</tbody></table><form method=\"post\" action=\"/users\" class=\"grid md:grid-cols-4 gap-3 items-end\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
|
||||
var templ_7745c5c3_Var33 string
|
||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 185, Col: 63}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 228, Col: 63}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
||||
_, 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, 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>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\"><div><label class=\"v-label\">Username</label> <input class=\"v-input\" name=\"username\"></div><div><label class=\"v-label\">Role</label> <select class=\"v-select\" name=\"role\"><option value=\"user\">user</option> <option value=\"admin\">admin</option></select></div><div><label class=\"v-label\">Initial Password</label> <input class=\"v-input\" type=\"password\" name=\"password\"></div><button class=\"v-btn\" type=\"submit\">Add User</button></form></section>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -559,6 +698,31 @@ func settingsBody(d SettingsData) templ.Component {
|
||||
})
|
||||
}
|
||||
|
||||
// currentEmail and the two opt-in accessors read the signed-in user's saved
|
||||
// email preferences off Page.CurrentUser, guarding the nil case.
|
||||
func currentEmail(d SettingsData) string {
|
||||
if d.CurrentUser == nil {
|
||||
return ""
|
||||
}
|
||||
return d.CurrentUser.Email
|
||||
}
|
||||
|
||||
func currentDealAlerts(d SettingsData) bool {
|
||||
return d.CurrentUser != nil && d.CurrentUser.EmailDealAlerts
|
||||
}
|
||||
|
||||
func currentWeeklyDigest(d SettingsData) bool {
|
||||
return d.CurrentUser != nil && d.CurrentUser.EmailWeeklyDigest
|
||||
}
|
||||
|
||||
// emailManagedExternally reports whether email is owned by Authentik and so is
|
||||
// read-only in Veola. True for any user when the deployment runs in forward-auth
|
||||
// mode (email is centralized in Authentik), and for an individual forward row
|
||||
// regardless of mode (the address is its IdP match key).
|
||||
func emailManagedExternally(d SettingsData) bool {
|
||||
return d.ForwardAuthMode || (d.CurrentUser != nil && d.CurrentUser.AuthSource == "forward")
|
||||
}
|
||||
|
||||
func Settings(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
|
||||
@@ -575,9 +739,9 @@ func Settings(d SettingsData) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var26 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var26 == nil {
|
||||
templ_7745c5c3_Var26 = templ.NopComponent
|
||||
templ_7745c5c3_Var34 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var34 == nil {
|
||||
templ_7745c5c3_Var34 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = Layout(d.Page, settingsBody(d)).Render(ctx, templ_7745c5c3_Buffer)
|
||||
|
||||
Reference in New Issue
Block a user