Initial commit
This commit is contained in:
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
# Binaries
|
||||
veola-bin
|
||||
*.exe
|
||||
|
||||
# Local config (use config.toml.example as template)
|
||||
config.toml
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
*.swp
|
||||
.idea/
|
||||
.vscode/
|
||||
102
README.md
Normal file
102
README.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Veola
|
||||
|
||||
Self-hosted Go web app that tracks items across e-commerce platforms (eBay, Amazon family, Yahoo Auctions JP, Mercari JP) via the [Apify](https://apify.com) scraping API and pushes deal alerts to a self-hosted [ntfy](https://ntfy.sh) instance.
|
||||
|
||||
Track. Watch. Notice.
|
||||
|
||||
## Features
|
||||
|
||||
- Watch arbitrary items across multiple marketplaces with per-item search queries, target prices, and poll intervals
|
||||
- Active-listing, sold-listing, and price-comparison actors per item
|
||||
- Price-history chart and best-price badge once enough history accumulates
|
||||
- Deal alerts pushed to ntfy when current price falls at or below target
|
||||
- Single-binary deploy, SQLite storage, no CGO
|
||||
|
||||
See [`veola-spec.md`](veola-spec.md) for the full specification.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Go 1.22+ (developed against 1.25)
|
||||
- An [Apify](https://apify.com) account + API key
|
||||
- A reachable [ntfy](https://ntfy.sh) instance (self-hosted or ntfy.sh)
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
go build -o veola-bin .
|
||||
```
|
||||
|
||||
The binary is named `veola-bin` rather than `veola` because the module is also `veola` — `go build` cannot write a binary with the same name as the module dir.
|
||||
|
||||
If you change any `.templ` files, regenerate first:
|
||||
|
||||
```sh
|
||||
~/go/bin/templ generate
|
||||
```
|
||||
|
||||
## Configure
|
||||
|
||||
Copy the example and edit:
|
||||
|
||||
```sh
|
||||
cp config.toml.example config.toml
|
||||
```
|
||||
|
||||
Both `session_secret` and `encryption_key` must be at least 32 bytes and different from each other. Generate with:
|
||||
|
||||
```sh
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
`encryption_key` encrypts secrets at rest in SQLite (Apify keys, ntfy settings). Rotating it invalidates stored secrets — re-enter them through `/settings` after rotation.
|
||||
|
||||
## Run
|
||||
|
||||
```sh
|
||||
./veola-bin -config config.toml
|
||||
```
|
||||
|
||||
First-run flow:
|
||||
|
||||
1. Visit `http://localhost:8080/`. With no users, you are redirected to `/setup`.
|
||||
2. Create the admin account.
|
||||
3. Log in at `/login`.
|
||||
4. Add items at `/items/new`. Optionally fill in your Apify key and ntfy URL via `/settings` if you didn't put them in `config.toml`.
|
||||
|
||||
The scheduler starts with the server and polls each active item on its configured interval. The bottom-of-hour global poll runs every `scheduler.global_poll_interval_minutes`.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
main.go entry point: config, db open, scheduler, http server
|
||||
internal/
|
||||
config/ TOML config loading and validation
|
||||
crypto/ AES-GCM encryption for secrets at rest
|
||||
db/ SQLite schema, migrations, store
|
||||
models/ domain types
|
||||
apify/ Apify API client
|
||||
ntfy/ ntfy push client
|
||||
auth/ session + CSRF
|
||||
scheduler/ poll loop, alert/dedup/badge logic
|
||||
handlers/ HTTP handlers
|
||||
templates/ templ components
|
||||
static/ CSS, vendored htmx
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Unit tests cover crypto round-trip, db round-trip and dedup, and scheduler alert/badge logic. No handler-level tests yet.
|
||||
|
||||
## Operate
|
||||
|
||||
- The SQLite file lives at `server.db_path` (default `./veola.db`). Back this up — it holds your watched items, history, encrypted secrets, and user accounts.
|
||||
- The process responds to `SIGINT` / `SIGTERM` with a graceful HTTP shutdown (30s timeout) followed by scheduler stop.
|
||||
- Logs go to stdout as structured `log/slog` records.
|
||||
|
||||
## Aesthetic
|
||||
|
||||
Sega Genesis blue. Not dark mode, not light mode — blue mode. See the visual design section of `veola-spec.md` for the palette.
|
||||
BIN
Veola.webp
Normal file
BIN
Veola.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
40
config.toml.example
Normal file
40
config.toml.example
Normal file
@@ -0,0 +1,40 @@
|
||||
[server]
|
||||
port = 8080
|
||||
db_path = "./veola.db"
|
||||
|
||||
[security]
|
||||
# Both must be at least 32 bytes and different from each other.
|
||||
# Generate with: openssl rand -hex 32
|
||||
session_secret = "change-this-to-a-random-32-byte-string-aaaa"
|
||||
encryption_key = "change-this-to-a-different-random-32-byte-string-bb"
|
||||
|
||||
[apify]
|
||||
api_key = ""
|
||||
|
||||
# Apify proxy configuration. If use_apify_proxy is false (or this whole
|
||||
# block is absent), Veola omits proxyConfiguration from actor input entirely
|
||||
# and the actor handles its own routing. RESIDENTIAL costs more credits and
|
||||
# requires a plan that includes it; AUTO (or empty groups) uses whatever
|
||||
# your plan provides.
|
||||
[apify.proxy]
|
||||
use_apify_proxy = false
|
||||
# groups = ["RESIDENTIAL"] # or [] for AUTO
|
||||
# country = "US" # ISO-3166-1 alpha-2; match your eBay region
|
||||
|
||||
# Actor IDs verified on apify.com/store at build time. Pricing varies; check
|
||||
# each actor's listing before enabling. Empty values disable that actor.
|
||||
[apify.actors]
|
||||
active_listings = "automation-lab/ebay-scraper"
|
||||
sold_listings = "automation-lab/ebay-sold-scraper"
|
||||
price_comparison = "" # set to a verified slug if you want price-comparison overlays
|
||||
yahoo_auctions_jp = "meron1122/zenmarket-scraper"
|
||||
yahoo_auctions_jp_sold = "" # no known verified sold-listings actor for Yahoo JP
|
||||
mercari_jp = "cloud9_ai/mercari-scraper"
|
||||
|
||||
[ntfy]
|
||||
base_url = "https://ntfy.yourdomain.com"
|
||||
default_topic = "veola"
|
||||
|
||||
[scheduler]
|
||||
global_poll_interval_minutes = 60
|
||||
match_confidence_threshold = 0.6
|
||||
23
go.mod
Normal file
23
go.mod
Normal file
@@ -0,0 +1,23 @@
|
||||
module veola
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/a-h/templ v0.3.1020 // indirect
|
||||
github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de // indirect
|
||||
github.com/alexedwards/scs/v2 v2.9.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.5 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
golang.org/x/crypto v0.51.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
modernc.org/libc v1.72.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.50.0 // indirect
|
||||
)
|
||||
36
go.sum
Normal file
36
go.sum
Normal file
@@ -0,0 +1,36 @@
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
||||
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
|
||||
github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de h1:c72K9HLu6K442et0j3BUL/9HEYaUJouLkkVANdmqTOo=
|
||||
github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de/go.mod h1:Iyk7S76cxGaiEX/mSYmTZzYehp4KfyylcLaV3OnToss=
|
||||
github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90=
|
||||
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/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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
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/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=
|
||||
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
|
||||
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
|
||||
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
||||
152
internal/apify/client.go
Normal file
152
internal/apify/client.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package apify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
apiBase = "https://api.apify.com/v2"
|
||||
pollEvery = 3 * time.Second
|
||||
pollTimeout = 5 * time.Minute
|
||||
)
|
||||
|
||||
// Client is a thin wrapper around the Apify run-and-fetch lifecycle.
|
||||
type Client struct {
|
||||
APIKey string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func New(apiKey string) *Client {
|
||||
return &Client{
|
||||
APIKey: apiKey,
|
||||
HTTP: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
type runResponse struct {
|
||||
Data struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
DefaultDatasetID string `json:"defaultDatasetId"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// Run starts an actor run, waits for SUCCEEDED, and returns dataset items as raw JSON.
|
||||
func (c *Client) Run(ctx context.Context, actorID string, input any) ([]json.RawMessage, error) {
|
||||
if c.APIKey == "" {
|
||||
return nil, errors.New("apify api_key not configured")
|
||||
}
|
||||
if actorID == "" {
|
||||
return nil, errors.New("apify actor id is empty")
|
||||
}
|
||||
|
||||
body, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Apify URLs use "~" to separate username and actor name, never "/".
|
||||
// Accept either form in config and normalize before path-escaping.
|
||||
urlActorID := strings.ReplaceAll(actorID, "/", "~")
|
||||
startURL := fmt.Sprintf("%s/acts/%s/runs?token=%s", apiBase, url.PathEscape(urlActorID), url.QueryEscape(c.APIKey))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, startURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("start run: %w", err)
|
||||
}
|
||||
var runResp runResponse
|
||||
if err := decodeJSON(resp, &runResp); err != nil {
|
||||
return nil, fmt.Errorf("start run: %w", err)
|
||||
}
|
||||
if runResp.Data.ID == "" {
|
||||
return nil, errors.New("start run: missing run id")
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(pollTimeout)
|
||||
pollCtx, cancel := context.WithDeadline(ctx, deadline)
|
||||
defer cancel()
|
||||
|
||||
status, datasetID, err := c.waitForRun(pollCtx, runResp.Data.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != "SUCCEEDED" {
|
||||
return nil, fmt.Errorf("apify run terminated with status %s", status)
|
||||
}
|
||||
|
||||
return c.fetchDataset(ctx, datasetID)
|
||||
}
|
||||
|
||||
func (c *Client) waitForRun(ctx context.Context, runID string) (string, string, error) {
|
||||
pollURL := fmt.Sprintf("%s/actor-runs/%s?token=%s", apiBase, url.PathEscape(runID), url.QueryEscape(c.APIKey))
|
||||
for {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pollURL, nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("poll run: %w", err)
|
||||
}
|
||||
var r runResponse
|
||||
if err := decodeJSON(resp, &r); err != nil {
|
||||
return "", "", fmt.Errorf("poll run: %w", err)
|
||||
}
|
||||
switch r.Data.Status {
|
||||
case "SUCCEEDED", "FAILED", "ABORTED", "TIMED-OUT":
|
||||
return r.Data.Status, r.Data.DefaultDatasetID, nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", "", ctx.Err()
|
||||
case <-time.After(pollEvery):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) fetchDataset(ctx context.Context, datasetID string) ([]json.RawMessage, error) {
|
||||
if datasetID == "" {
|
||||
return nil, errors.New("missing dataset id")
|
||||
}
|
||||
dsURL := fmt.Sprintf("%s/datasets/%s/items?clean=true&format=json&token=%s", apiBase, url.PathEscape(datasetID), url.QueryEscape(c.APIKey))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, dsURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch dataset: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return nil, fmt.Errorf("dataset returned %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
var items []json.RawMessage
|
||||
if err := json.NewDecoder(resp.Body).Decode(&items); err != nil {
|
||||
return nil, fmt.Errorf("decode dataset: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func decodeJSON(resp *http.Response, dst any) error {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return fmt.Errorf("http %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(dst)
|
||||
}
|
||||
313
internal/apify/types.go
Normal file
313
internal/apify/types.go
Normal file
@@ -0,0 +1,313 @@
|
||||
package apify
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ActiveListingInput is the input schema for `automation-lab/ebay-scraper`.
|
||||
// The actor accepts keyword searches and standard filters; it targets
|
||||
// ebay.com only (no per-marketplace routing in the actor itself), so
|
||||
// non-US marketplaces won't return useful results with this actor.
|
||||
type ActiveListingInput struct {
|
||||
SearchQueries []string `json:"searchQueries"`
|
||||
MaxProductsPerSearch int `json:"maxProductsPerSearch,omitempty"`
|
||||
MaxSearchPages int `json:"maxSearchPages,omitempty"`
|
||||
Sort string `json:"sort,omitempty"`
|
||||
ListingType string `json:"listingType,omitempty"`
|
||||
Condition []string `json:"condition,omitempty"`
|
||||
MinPrice *int `json:"minPrice,omitempty"`
|
||||
MaxPrice *int `json:"maxPrice,omitempty"`
|
||||
ProxyConfiguration *ProxyConfiguration `json:"proxyConfiguration,omitempty"`
|
||||
}
|
||||
|
||||
// ProxyConfiguration is the standard apify input block for proxy routing.
|
||||
// eBay (and most retail sites) return 403 to datacenter IPs; passing
|
||||
// {"useApifyProxy": true, "apifyProxyGroups": ["RESIDENTIAL"]} works.
|
||||
type ProxyConfiguration struct {
|
||||
UseApifyProxy bool `json:"useApifyProxy"`
|
||||
ApifyProxyGroups []string `json:"apifyProxyGroups,omitempty"`
|
||||
ApifyProxyCountry string `json:"apifyProxyCountry,omitempty"`
|
||||
}
|
||||
|
||||
// ActiveListingResult is decoded leniently to handle multiple eBay-scraper
|
||||
// actors. delicious_zebu/ebay-product-listing-scraper returns productUrl /
|
||||
// imageUrl / numeric price; harvestlab/ebay-scraper used url / price /
|
||||
// currency. The decoder coalesces both shapes.
|
||||
type ActiveListingResult struct {
|
||||
Title string `json:"title"`
|
||||
Price any `json:"price"`
|
||||
OriginalPrice any `json:"originalPrice"`
|
||||
Currency string `json:"currency"`
|
||||
URL string `json:"url"`
|
||||
ProductURL string `json:"productUrl"`
|
||||
Store string `json:"store"`
|
||||
ImageURL string `json:"imageUrl"`
|
||||
Image string `json:"image"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Images []string `json:"images"`
|
||||
Condition string `json:"condition"`
|
||||
ListingType string `json:"listingType"`
|
||||
ShippingCost any `json:"shippingCost"`
|
||||
ShippingPrice any `json:"shippingPrice"`
|
||||
FreeShipping bool `json:"freeShipping"`
|
||||
Marketplace string `json:"marketplace"`
|
||||
MatchConfidence float64 `json:"matchConfidence"`
|
||||
Availability string `json:"availability"`
|
||||
WatchersCount int `json:"watchersCount"`
|
||||
QuantitySold int `json:"quantitySold"`
|
||||
}
|
||||
|
||||
type SoldListingInput struct {
|
||||
Query string `json:"query"`
|
||||
Marketplace string `json:"marketplace,omitempty"`
|
||||
MaxResults int `json:"maxResults,omitempty"`
|
||||
DaysBack int `json:"daysBack,omitempty"`
|
||||
ProxyConfiguration *ProxyConfiguration `json:"proxyConfiguration,omitempty"`
|
||||
}
|
||||
|
||||
type SoldListingResult struct {
|
||||
Title string `json:"title"`
|
||||
SoldPrice float64 `json:"soldPrice"`
|
||||
Currency string `json:"soldCurrency"`
|
||||
SoldAt string `json:"endedAt"`
|
||||
Condition string `json:"condition"`
|
||||
ListingType string `json:"listingType"`
|
||||
ShippingPrice float64 `json:"shippingPrice"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type PriceComparisonInput struct {
|
||||
Query string `json:"query,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
MatchStrictness string `json:"matchStrictness,omitempty"`
|
||||
ProxyConfiguration *ProxyConfiguration `json:"proxyConfiguration,omitempty"`
|
||||
}
|
||||
|
||||
type PriceComparisonResult struct {
|
||||
Title string `json:"title"`
|
||||
Price float64 `json:"price"`
|
||||
Currency string `json:"currency"`
|
||||
URL string `json:"url"`
|
||||
Store string `json:"store"`
|
||||
ImageURL string `json:"imageUrl"`
|
||||
Availability string `json:"availability"`
|
||||
MatchConfidence float64 `json:"matchConfidence"`
|
||||
}
|
||||
|
||||
// YahooAuctionsJPInput targets meron1122/zenmarket-scraper. ZenMarket is a
|
||||
// buyer-proxy for Yahoo Auctions JP; its scraper returns ZenMarket-proxied
|
||||
// listing URLs and USD-converted prices.
|
||||
type YahooAuctionsJPInput struct {
|
||||
SearchTerm string `json:"searchTerm"`
|
||||
CategoryID string `json:"categoryID,omitempty"`
|
||||
MaxPages int `json:"maxPages,omitempty"`
|
||||
MaxRemainingHours int `json:"maxRemainingHours,omitempty"`
|
||||
}
|
||||
|
||||
// MercariJPInput targets cloud9_ai/mercari-scraper. The actor manages its
|
||||
// own proxy (Japan datacenter with residential fallback), so we do not send
|
||||
// a proxyConfiguration block.
|
||||
type MercariJPInput struct {
|
||||
SearchKeywords []string `json:"searchKeywords,omitempty"`
|
||||
ProductUrls []string `json:"productUrls,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
SortBy string `json:"sortBy,omitempty"`
|
||||
PriceMin *int `json:"priceMin,omitempty"`
|
||||
PriceMax *int `json:"priceMax,omitempty"`
|
||||
ItemCondition string `json:"itemCondition,omitempty"`
|
||||
MaxResults int `json:"maxResults,omitempty"`
|
||||
}
|
||||
|
||||
// YahooAuctionsJPResult matches meron1122/zenmarket-scraper output. Prices
|
||||
// are USD-converted at the ZenMarket-published rate.
|
||||
type YahooAuctionsJPResult struct {
|
||||
Name string `json:"name"`
|
||||
CurrentPrice any `json:"current_price"`
|
||||
Photos []string `json:"photos"`
|
||||
URL string `json:"url"`
|
||||
EndingDate string `json:"ending_date"`
|
||||
}
|
||||
|
||||
// UnifiedResult is the common shape produced by ParseResults regardless of
|
||||
// which actor type returned the data. The scheduler consumes this.
|
||||
type UnifiedResult struct {
|
||||
Title string
|
||||
Price float64
|
||||
Currency string
|
||||
URL string
|
||||
Store string
|
||||
ImageURL string
|
||||
Source string
|
||||
MatchConfidence float64
|
||||
OutOfStock bool
|
||||
// MatchedQuery records which alias from the item's query list produced
|
||||
// this row. Empty for URL-only items or rows from non-search sources.
|
||||
MatchedQuery string
|
||||
}
|
||||
|
||||
// Decode unmarshals a list of raw JSON items into UnifiedResult slices using
|
||||
// the shape that matches the given source label.
|
||||
func Decode(items []json.RawMessage, source string) ([]UnifiedResult, error) {
|
||||
out := make([]UnifiedResult, 0, len(items))
|
||||
switch source {
|
||||
case SourceActiveEbay, SourcePriceCompare:
|
||||
for _, raw := range items {
|
||||
var r ActiveListingResult
|
||||
if err := json.Unmarshal(raw, &r); err != nil {
|
||||
continue
|
||||
}
|
||||
url := r.URL
|
||||
if url == "" {
|
||||
url = r.ProductURL
|
||||
}
|
||||
img := r.ImageURL
|
||||
if img == "" {
|
||||
img = r.Image
|
||||
}
|
||||
if img == "" {
|
||||
img = r.Thumbnail
|
||||
}
|
||||
if img == "" && len(r.Images) > 0 {
|
||||
img = r.Images[0]
|
||||
}
|
||||
store := r.Store
|
||||
if store == "" {
|
||||
store = r.Marketplace
|
||||
}
|
||||
if store == "" && source == SourceActiveEbay {
|
||||
store = "ebay"
|
||||
}
|
||||
cur := r.Currency
|
||||
if cur == "" {
|
||||
cur = "USD"
|
||||
}
|
||||
out = append(out, UnifiedResult{
|
||||
Title: r.Title,
|
||||
Price: coercePrice(r.Price),
|
||||
Currency: cur,
|
||||
URL: url,
|
||||
Store: store,
|
||||
ImageURL: img,
|
||||
Source: source,
|
||||
MatchConfidence: r.MatchConfidence,
|
||||
OutOfStock: isOOS(r.Availability),
|
||||
})
|
||||
}
|
||||
case SourceYahooJP:
|
||||
for _, raw := range items {
|
||||
var r YahooAuctionsJPResult
|
||||
if err := json.Unmarshal(raw, &r); err != nil {
|
||||
continue
|
||||
}
|
||||
img := ""
|
||||
if len(r.Photos) > 0 {
|
||||
img = r.Photos[0]
|
||||
}
|
||||
out = append(out, UnifiedResult{
|
||||
Title: r.Name,
|
||||
Price: coercePrice(r.CurrentPrice),
|
||||
Currency: "USD",
|
||||
URL: r.URL,
|
||||
Store: "yahoo-auctions-jp (via zenmarket)",
|
||||
ImageURL: img,
|
||||
Source: source,
|
||||
})
|
||||
}
|
||||
case SourceMercariJP:
|
||||
// Mercari actors vary in shape; accept either price/currentPrice and title/name.
|
||||
for _, raw := range items {
|
||||
var generic struct {
|
||||
Title string `json:"title"`
|
||||
Name string `json:"name"`
|
||||
Price float64 `json:"price"`
|
||||
CurrentPrice float64 `json:"currentPrice"`
|
||||
Currency string `json:"currency"`
|
||||
URL string `json:"url"`
|
||||
ImageURL string `json:"imageUrl"`
|
||||
Image string `json:"image"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &generic); err != nil {
|
||||
continue
|
||||
}
|
||||
title := generic.Title
|
||||
if title == "" {
|
||||
title = generic.Name
|
||||
}
|
||||
price := generic.Price
|
||||
if price == 0 {
|
||||
price = generic.CurrentPrice
|
||||
}
|
||||
img := generic.ImageURL
|
||||
if img == "" {
|
||||
img = generic.Image
|
||||
}
|
||||
cur := generic.Currency
|
||||
if cur == "" {
|
||||
cur = "JPY"
|
||||
}
|
||||
out = append(out, UnifiedResult{
|
||||
Title: title,
|
||||
Price: price,
|
||||
Currency: cur,
|
||||
URL: generic.URL,
|
||||
Store: "mercari-jp",
|
||||
ImageURL: img,
|
||||
Source: source,
|
||||
OutOfStock: isOOS(generic.Status),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
const (
|
||||
SourceActiveEbay = "ebay"
|
||||
SourcePriceCompare = "price-comparison"
|
||||
SourceYahooJP = "yahoo-auctions-jp"
|
||||
SourceMercariJP = "mercari-jp"
|
||||
SourceSoldEbay = "ebay-sold"
|
||||
SourceSoldYahooJP = "yahoo-auctions-jp-sold"
|
||||
)
|
||||
|
||||
// coercePrice accepts a price field that might be a number or a string with
|
||||
// currency symbols / commas (e.g. "$24.99", "1,299.00"). Returns 0 on failure
|
||||
// so FilterResults can drop the row cleanly.
|
||||
func coercePrice(v any) float64 {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return 0
|
||||
case float64:
|
||||
return x
|
||||
case float32:
|
||||
return float64(x)
|
||||
case int:
|
||||
return float64(x)
|
||||
case int64:
|
||||
return float64(x)
|
||||
case string:
|
||||
s := strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r >= '0' && r <= '9', r == '.', r == '-':
|
||||
return r
|
||||
}
|
||||
return -1
|
||||
}, x)
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return f
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func isOOS(s string) bool {
|
||||
switch s {
|
||||
case "out_of_stock", "OUT_OF_STOCK", "sold", "SOLD", "ended":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
214
internal/auth/auth.go
Normal file
214
internal/auth/auth.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/scs/sqlite3store"
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"veola/internal/db"
|
||||
"veola/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
BcryptCost = 12
|
||||
MinPasswordLen = 12
|
||||
|
||||
sessionUserIDKey = "user_id"
|
||||
sessionCSRFKey = "csrf_token"
|
||||
|
||||
csrfFormField = "csrf_token"
|
||||
csrfHeaderName = "X-CSRF-Token"
|
||||
)
|
||||
|
||||
// Manager bundles session manager + DB store and serves as the auth surface.
|
||||
type Manager struct {
|
||||
Sessions *scs.SessionManager
|
||||
Store *db.Store
|
||||
hmacKey []byte
|
||||
}
|
||||
|
||||
func NewManager(sqlDB *sql.DB, store *db.Store, sessionSecret string) (*Manager, error) {
|
||||
if len(sessionSecret) < 32 {
|
||||
return nil, errors.New("session secret too short")
|
||||
}
|
||||
sm := scs.New()
|
||||
sm.Store = sqlite3store.New(sqlDB)
|
||||
sm.Lifetime = 7 * 24 * time.Hour
|
||||
sm.IdleTimeout = 7 * 24 * time.Hour
|
||||
sm.Cookie.Name = "veola_session"
|
||||
sm.Cookie.HttpOnly = true
|
||||
sm.Cookie.Path = "/"
|
||||
sm.Cookie.SameSite = http.SameSiteLaxMode
|
||||
sm.Cookie.Persist = true
|
||||
// Cookie.Secure left false for self-hosted HTTP deployments; flip via env in deploy.
|
||||
|
||||
mac := sha256.New()
|
||||
mac.Write([]byte(sessionSecret))
|
||||
return &Manager{Sessions: sm, Store: store, hmacKey: mac.Sum(nil)}, nil
|
||||
}
|
||||
|
||||
func HashPassword(plain string) (string, error) {
|
||||
b, err := bcrypt.GenerateFromPassword([]byte(plain), BcryptCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func CheckPassword(hash, plain string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
|
||||
}
|
||||
|
||||
// LogIn writes the user id into the session and rotates the token.
|
||||
func (m *Manager) LogIn(ctx context.Context, userID int64) error {
|
||||
if err := m.Sessions.RenewToken(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
m.Sessions.Put(ctx, sessionUserIDKey, userID)
|
||||
m.Sessions.Put(ctx, sessionCSRFKey, newCSRFToken())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) LogOut(ctx context.Context) error {
|
||||
return m.Sessions.Destroy(ctx)
|
||||
}
|
||||
|
||||
func (m *Manager) UserID(ctx context.Context) int64 {
|
||||
return m.Sessions.GetInt64(ctx, sessionUserIDKey)
|
||||
}
|
||||
|
||||
func (m *Manager) CurrentUser(ctx context.Context) (*models.User, error) {
|
||||
id := m.UserID(ctx)
|
||||
if id == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return m.Store.GetUserByID(ctx, id)
|
||||
}
|
||||
|
||||
// ============ CSRF ============
|
||||
|
||||
func newCSRFToken() string {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// extremely unlikely; fall back to a time-based token rather than crashing
|
||||
return hex.EncodeToString([]byte(time.Now().String()))
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func (m *Manager) CSRFToken(ctx context.Context) string {
|
||||
tok := m.Sessions.GetString(ctx, sessionCSRFKey)
|
||||
if tok == "" {
|
||||
tok = newCSRFToken()
|
||||
m.Sessions.Put(ctx, sessionCSRFKey, tok)
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
// CSRFFieldName is the HTML form field expected by the middleware.
|
||||
func CSRFFieldName() string { return csrfFormField }
|
||||
|
||||
// ============ Middleware ============
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const (
|
||||
ctxKeyUser ctxKey = iota
|
||||
)
|
||||
|
||||
func userFromContext(ctx context.Context) *models.User {
|
||||
u, _ := ctx.Value(ctxKeyUser).(*models.User)
|
||||
return u
|
||||
}
|
||||
|
||||
// CurrentUserFromRequest is the public accessor for handlers and templates.
|
||||
func CurrentUserFromRequest(r *http.Request) *models.User {
|
||||
return userFromContext(r.Context())
|
||||
}
|
||||
|
||||
// LoadUser populates the user into the context for any logged-in session.
|
||||
// Routes still need RequireAuth/RequireAdmin to gate access.
|
||||
func (m *Manager) LoadUser(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u, err := m.CurrentUser(r.Context())
|
||||
if err == nil && u != nil {
|
||||
ctx := context.WithValue(r.Context(), ctxKeyUser, u)
|
||||
r = r.WithContext(ctx)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// RequireAuth redirects to /login if no user is present. Skips /login, /setup,
|
||||
// /static. The setup-gate (redirect to /setup if no users exist) is applied
|
||||
// at the router level via SetupGate so it can short-circuit before auth runs.
|
||||
func (m *Manager) RequireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if userFromContext(r.Context()) == nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) RequireAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u := userFromContext(r.Context())
|
||||
if u == nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if u.Role != models.RoleAdmin {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// CSRFProtect validates the CSRF token on non-idempotent requests.
|
||||
func (m *Manager) CSRFProtect(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
expected := m.Sessions.GetString(r.Context(), sessionCSRFKey)
|
||||
if expected == "" {
|
||||
http.Error(w, "csrf token missing from session", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
got := r.Header.Get(csrfHeaderName)
|
||||
if got == "" {
|
||||
if err := r.ParseForm(); err == nil {
|
||||
got = r.PostFormValue(csrfFormField)
|
||||
}
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(got), []byte(expected)) != 1 {
|
||||
http.Error(w, "invalid csrf token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// HMAC is exposed for non-session use cases (e.g. signed setup links). Not
|
||||
// currently called by handlers but kept available since the secret is loaded.
|
||||
func (m *Manager) HMAC(payload []byte) string {
|
||||
h := hmac.New(sha256.New, m.hmacKey)
|
||||
h.Write(payload)
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
125
internal/config/config.go
Normal file
125
internal/config/config.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `toml:"server"`
|
||||
Security SecurityConfig `toml:"security"`
|
||||
Apify ApifyConfig `toml:"apify"`
|
||||
Ntfy NtfyConfig `toml:"ntfy"`
|
||||
Scheduler SchedulerConfig `toml:"scheduler"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int `toml:"port"`
|
||||
DBPath string `toml:"db_path"`
|
||||
}
|
||||
|
||||
type SecurityConfig struct {
|
||||
SessionSecret string `toml:"session_secret"`
|
||||
EncryptionKey string `toml:"encryption_key"`
|
||||
}
|
||||
|
||||
type ApifyConfig struct {
|
||||
APIKey string `toml:"api_key"`
|
||||
Actors ActorConfig `toml:"actors"`
|
||||
Proxy ProxyConfig `toml:"proxy"`
|
||||
}
|
||||
|
||||
// ProxyConfig controls the proxyConfiguration block passed to apify actors
|
||||
// that scrape sites which block datacenter IPs (e.g. eBay returns 403 without
|
||||
// a residential proxy).
|
||||
type ProxyConfig struct {
|
||||
UseApifyProxy bool `toml:"use_apify_proxy"`
|
||||
Groups []string `toml:"groups"`
|
||||
Country string `toml:"country"`
|
||||
}
|
||||
|
||||
type ActorConfig struct {
|
||||
ActiveListings string `toml:"active_listings"`
|
||||
SoldListings string `toml:"sold_listings"`
|
||||
PriceComparison string `toml:"price_comparison"`
|
||||
YahooAuctionsJP string `toml:"yahoo_auctions_jp"`
|
||||
YahooAuctionsJPSold string `toml:"yahoo_auctions_jp_sold"`
|
||||
MercariJP string `toml:"mercari_jp"`
|
||||
}
|
||||
|
||||
type NtfyConfig struct {
|
||||
BaseURL string `toml:"base_url"`
|
||||
DefaultTopic string `toml:"default_topic"`
|
||||
}
|
||||
|
||||
type SchedulerConfig struct {
|
||||
GlobalPollIntervalMinutes int `toml:"global_poll_interval_minutes"`
|
||||
MatchConfidenceThreshold float64 `toml:"match_confidence_threshold"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("config file not found at %s. Copy config.toml.example to that path and fill it in", path)
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("stat config: %w", err)
|
||||
}
|
||||
|
||||
var c Config
|
||||
if _, err := toml.DecodeFile(path, &c); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
|
||||
if err := c.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (c *Config) validate() error {
|
||||
if len(c.Security.SessionSecret) < 32 {
|
||||
return errors.New("security.session_secret must be at least 32 bytes")
|
||||
}
|
||||
if len(c.Security.EncryptionKey) < 32 {
|
||||
return errors.New("security.encryption_key must be at least 32 bytes")
|
||||
}
|
||||
if c.Security.SessionSecret == c.Security.EncryptionKey {
|
||||
return errors.New("security.session_secret and security.encryption_key must not be equal")
|
||||
}
|
||||
if c.Server.DBPath == "" {
|
||||
return errors.New("server.db_path must be set")
|
||||
}
|
||||
dir := filepath.Dir(c.Server.DBPath)
|
||||
if dir == "" {
|
||||
dir = "."
|
||||
}
|
||||
if err := checkWritable(dir); err != nil {
|
||||
return fmt.Errorf("server.db_path directory %s not writable: %w", dir, err)
|
||||
}
|
||||
if c.Server.Port == 0 {
|
||||
c.Server.Port = 8080
|
||||
}
|
||||
if c.Scheduler.GlobalPollIntervalMinutes == 0 {
|
||||
c.Scheduler.GlobalPollIntervalMinutes = 60
|
||||
}
|
||||
if c.Scheduler.MatchConfidenceThreshold == 0 {
|
||||
c.Scheduler.MatchConfidenceThreshold = 0.6
|
||||
}
|
||||
if c.Ntfy.DefaultTopic == "" {
|
||||
c.Ntfy.DefaultTopic = "veola"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkWritable(dir string) error {
|
||||
f, err := os.CreateTemp(dir, ".veola-write-test-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := f.Name()
|
||||
f.Close()
|
||||
return os.Remove(name)
|
||||
}
|
||||
94
internal/crypto/crypto.go
Normal file
94
internal/crypto/crypto.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/hkdf"
|
||||
)
|
||||
|
||||
const (
|
||||
prefix = "enc:"
|
||||
infoLabel = "veola-v1"
|
||||
keyLen = 32
|
||||
nonceLen = 12
|
||||
)
|
||||
|
||||
// DeriveKey derives a 32-byte AES key from raw key material via HKDF-SHA256.
|
||||
func DeriveKey(rawKey []byte) ([]byte, error) {
|
||||
if len(rawKey) < 32 {
|
||||
return nil, errors.New("raw encryption key must be at least 32 bytes")
|
||||
}
|
||||
r := hkdf.New(sha256.New, rawKey, nil, []byte(infoLabel))
|
||||
out := make([]byte, keyLen)
|
||||
if _, err := io.ReadFull(r, out); err != nil {
|
||||
return nil, fmt.Errorf("derive key: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func Encrypt(key []byte, plaintext string) (string, error) {
|
||||
if plaintext == "" {
|
||||
return "", nil
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, nonceLen)
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ct := gcm.Seal(nil, nonce, []byte(plaintext), nil)
|
||||
buf := make([]byte, 0, len(nonce)+len(ct))
|
||||
buf = append(buf, nonce...)
|
||||
buf = append(buf, ct...)
|
||||
return prefix + base64.StdEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func Decrypt(key []byte, value string) (string, error) {
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
if !IsEncrypted(value) {
|
||||
// Plaintext passthrough lets us decrypt rows that pre-date encryption
|
||||
// without a separate migration.
|
||||
return value, nil
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(value, prefix))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode ciphertext: %w", err)
|
||||
}
|
||||
if len(raw) < nonceLen {
|
||||
return "", errors.New("ciphertext too short")
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce, ct := raw[:nonceLen], raw[nonceLen:]
|
||||
pt, err := gcm.Open(nil, nonce, ct, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decrypt: %w", err)
|
||||
}
|
||||
return string(pt), nil
|
||||
}
|
||||
|
||||
func IsEncrypted(value string) bool {
|
||||
return strings.HasPrefix(value, prefix)
|
||||
}
|
||||
86
internal/crypto/crypto_test.go
Normal file
86
internal/crypto/crypto_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testKey(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
k, err := DeriveKey([]byte("0123456789abcdef0123456789abcdef-aaa"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
k := testKey(t)
|
||||
cases := []string{
|
||||
"hello",
|
||||
"",
|
||||
"ツインビー グラディウス パロディウス",
|
||||
strings.Repeat("a", 4096),
|
||||
"line1\nline2\ttab",
|
||||
}
|
||||
for _, pt := range cases {
|
||||
ct, err := Encrypt(k, pt)
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt %q: %v", pt, err)
|
||||
}
|
||||
if pt != "" && !IsEncrypted(ct) {
|
||||
t.Errorf("expected enc: prefix on %q", ct)
|
||||
}
|
||||
got, err := Decrypt(k, ct)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt: %v", err)
|
||||
}
|
||||
if got != pt {
|
||||
t.Errorf("round-trip mismatch: got %q want %q", got, pt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonceUnique(t *testing.T) {
|
||||
k := testKey(t)
|
||||
a, _ := Encrypt(k, "same plaintext")
|
||||
b, _ := Encrypt(k, "same plaintext")
|
||||
if a == b {
|
||||
t.Error("two encryptions of the same plaintext produced identical ciphertext (nonce not random)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTamperRejected(t *testing.T) {
|
||||
k := testKey(t)
|
||||
ct, _ := Encrypt(k, "secret")
|
||||
tampered := ct[:len(ct)-2] + "AA"
|
||||
if _, err := Decrypt(k, tampered); err == nil {
|
||||
t.Error("expected tampered ciphertext to fail decryption")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongKeyRejected(t *testing.T) {
|
||||
k1 := testKey(t)
|
||||
k2, _ := DeriveKey([]byte("a-different-32-byte-key-aaaaaaaaaaaa"))
|
||||
ct, _ := Encrypt(k1, "secret")
|
||||
if _, err := Decrypt(k2, ct); err == nil {
|
||||
t.Error("expected decryption with wrong key to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaintextPassthrough(t *testing.T) {
|
||||
k := testKey(t)
|
||||
got, err := Decrypt(k, "not-encrypted")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "not-encrypted" {
|
||||
t.Errorf("plaintext passthrough failed: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveKeyRequiresMinLength(t *testing.T) {
|
||||
if _, err := DeriveKey([]byte("too short")); err == nil {
|
||||
t.Error("expected error on short key material")
|
||||
}
|
||||
}
|
||||
65
internal/db/db.go
Normal file
65
internal/db/db.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//go:embed schema.sql
|
||||
var schemaSQL string
|
||||
|
||||
func Open(path string) (*sql.DB, error) {
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(5000)", path)
|
||||
conn, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
if err := conn.Ping(); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("ping sqlite: %w", err)
|
||||
}
|
||||
if _, err := conn.Exec(schemaSQL); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("apply schema: %w", err)
|
||||
}
|
||||
if err := addColumnIfMissing(conn, "items", "min_price", "REAL"); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := addColumnIfMissing(conn, "items", "exclude_keywords", "TEXT"); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := addColumnIfMissing(conn, "results", "matched_query", "TEXT"); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func addColumnIfMissing(conn *sql.DB, table, column, typ string) error {
|
||||
rows, err := conn.Query(fmt.Sprintf(`PRAGMA table_info(%s)`, table))
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect %s: %w", table, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, ctype string
|
||||
var notnull, pk int
|
||||
var dflt sql.NullString
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil {
|
||||
return err
|
||||
}
|
||||
if name == column {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if _, err := conn.Exec(fmt.Sprintf(`ALTER TABLE %s ADD COLUMN %s %s`, table, column, typ)); err != nil {
|
||||
return fmt.Errorf("add column %s.%s: %w", table, column, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
97
internal/db/dedup_test.go
Normal file
97
internal/db/dedup_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"veola/internal/crypto"
|
||||
"veola/internal/models"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
conn, err := Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
key, _ := crypto.DeriveKey([]byte("0123456789abcdef0123456789abcdef-aaa"))
|
||||
return NewStore(conn, key)
|
||||
}
|
||||
|
||||
func TestDedupByItemAndURL(t *testing.T) {
|
||||
if os.Getenv("CI_SKIP_SQLITE") != "" {
|
||||
t.Skip()
|
||||
}
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := s.CreateItem(ctx, &models.Item{
|
||||
Name: "TwinBee", NtfyTopic: "veola", Active: true,
|
||||
PollIntervalMinutes: 60, NtfyPriority: "default",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := &models.Result{
|
||||
ItemID: id,
|
||||
Title: "TwinBee Famicom",
|
||||
Currency: "USD",
|
||||
URL: "https://example.com/listing/1",
|
||||
}
|
||||
if _, err := s.InsertResult(ctx, r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
exists, err := s.ResultExists(ctx, id, "https://example.com/listing/1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !exists {
|
||||
t.Error("expected result to be detected as duplicate")
|
||||
}
|
||||
|
||||
missing, _ := s.ResultExists(ctx, id, "https://example.com/listing/2")
|
||||
if missing {
|
||||
t.Error("expected unknown URL to not be flagged as duplicate")
|
||||
}
|
||||
|
||||
// Different item, same URL should not collide.
|
||||
id2, _ := s.CreateItem(ctx, &models.Item{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")
|
||||
}
|
||||
|
||||
// Empty URL should not collide.
|
||||
emptyExists, _ := s.ResultExists(ctx, id, "")
|
||||
if emptyExists {
|
||||
t.Error("empty URL should never be flagged as duplicate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCJKRoundTrip(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
id, err := s.CreateItem(ctx, &models.Item{
|
||||
Name: "ツインビー",
|
||||
SearchQuery: "ツインビー グラディウス パロディウス",
|
||||
NtfyTopic: "veola",
|
||||
Active: true,
|
||||
PollIntervalMinutes: 60, NtfyPriority: "default",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.GetItem(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Name != "ツインビー" || got.SearchQuery != "ツインビー グラディウス パロディウス" {
|
||||
t.Errorf("CJK round-trip failed: name=%q query=%q", got.Name, got.SearchQuery)
|
||||
}
|
||||
}
|
||||
747
internal/db/queries.go
Normal file
747
internal/db/queries.go
Normal file
@@ -0,0 +1,747 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"veola/internal/crypto"
|
||||
"veola/internal/models"
|
||||
)
|
||||
|
||||
// Store wraps a *sql.DB with the encryption key used for column-level crypto.
|
||||
type Store struct {
|
||||
DB *sql.DB
|
||||
Key []byte
|
||||
}
|
||||
|
||||
func NewStore(db *sql.DB, key []byte) *Store {
|
||||
return &Store{DB: db, Key: key}
|
||||
}
|
||||
|
||||
// enc encrypts plaintext, logging and returning empty string on failure.
|
||||
func (s *Store) enc(plain string) string {
|
||||
if plain == "" {
|
||||
return ""
|
||||
}
|
||||
v, err := crypto.Encrypt(s.Key, plain)
|
||||
if err != nil {
|
||||
slog.Error("encrypt failed", "err", err)
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// dec decrypts a value; on failure returns "" per spec line 333.
|
||||
func (s *Store) dec(v string) string {
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
out, err := crypto.Decrypt(s.Key, v)
|
||||
if err != nil {
|
||||
slog.Error("decrypt failed", "err", err)
|
||||
return ""
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func nullStr(s string) sql.NullString {
|
||||
if s == "" {
|
||||
return sql.NullString{}
|
||||
}
|
||||
return sql.NullString{String: s, Valid: true}
|
||||
}
|
||||
|
||||
func nullFloat(f *float64) sql.NullFloat64 {
|
||||
if f == nil {
|
||||
return sql.NullFloat64{}
|
||||
}
|
||||
return sql.NullFloat64{Float64: *f, Valid: true}
|
||||
}
|
||||
|
||||
func nullTime(t *time.Time) sql.NullTime {
|
||||
if t == nil {
|
||||
return sql.NullTime{}
|
||||
}
|
||||
return sql.NullTime{Time: *t, Valid: true}
|
||||
}
|
||||
|
||||
func ptrFloat(f sql.NullFloat64) *float64 {
|
||||
if !f.Valid {
|
||||
return nil
|
||||
}
|
||||
v := f.Float64
|
||||
return &v
|
||||
}
|
||||
|
||||
func ptrTime(t sql.NullTime) *time.Time {
|
||||
if !t.Valid {
|
||||
return nil
|
||||
}
|
||||
v := t.Time
|
||||
return &v
|
||||
}
|
||||
|
||||
// ============ users ============
|
||||
|
||||
func (s *Store) UserCount(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
err := s.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
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 (?, ?, ?)`,
|
||||
username, hash, string(role))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
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.Role = models.Role(role)
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
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.Role = models.Role(role)
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
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`)
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
u.Role = models.Role(role)
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
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)
|
||||
return err
|
||||
}
|
||||
|
||||
// ============ settings ============
|
||||
|
||||
func (s *Store) GetSetting(ctx context.Context, key string) (string, error) {
|
||||
var v sql.NullString
|
||||
err := s.DB.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !v.Valid {
|
||||
return "", nil
|
||||
}
|
||||
return s.dec(v.String), nil
|
||||
}
|
||||
|
||||
func (s *Store) GetAllSettings(ctx context.Context) (map[string]string, error) {
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT key, value FROM settings`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]string)
|
||||
for rows.Next() {
|
||||
var k string
|
||||
var v sql.NullString
|
||||
if err := rows.Scan(&k, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if v.Valid {
|
||||
out[k] = s.dec(v.String)
|
||||
} else {
|
||||
out[k] = ""
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) SetSetting(ctx context.Context, key, value string) error {
|
||||
enc := s.enc(value)
|
||||
_, err := s.DB.ExecContext(ctx, `
|
||||
INSERT INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP
|
||||
`, key, enc)
|
||||
return err
|
||||
}
|
||||
|
||||
// ============ 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,
|
||||
poll_interval_minutes, include_out_of_stock, min_price, exclude_keywords,
|
||||
listing_type,
|
||||
actor_active, actor_sold, actor_price_compare, use_price_comparison,
|
||||
active, best_price, best_price_store, best_price_url, best_price_image_url,
|
||||
best_price_title, last_polled_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
it.Name, s.enc(it.SearchQuery), nullStr(it.URL), nullStr(it.Category),
|
||||
nullFloat(it.TargetPrice), s.enc(it.NtfyTopic), it.NtfyPriority,
|
||||
it.PollIntervalMinutes, boolToInt(it.IncludeOutOfStock),
|
||||
nullFloat(it.MinPrice), nullStr(s.enc(it.ExcludeKeywords)),
|
||||
nullStr(it.ListingType),
|
||||
nullStr(it.ActorActive), nullStr(it.ActorSold), nullStr(it.ActorPriceCompare),
|
||||
boolToInt(it.UsePriceComparison), boolToInt(it.Active),
|
||||
nullFloat(it.BestPrice), nullStr(it.BestPriceStore),
|
||||
nullStr(s.enc(it.BestPriceURL)), nullStr(s.enc(it.BestPriceImageURL)),
|
||||
nullStr(s.enc(it.BestPriceTitle)), nullTime(it.LastPolledAt),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := s.SetItemMarketplaces(ctx, id, it.Marketplaces); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateItem(ctx context.Context, it *models.Item) error {
|
||||
if _, err := s.DB.ExecContext(ctx, `
|
||||
UPDATE items SET
|
||||
name = ?, search_query = ?, url = ?, category = ?, target_price = ?,
|
||||
ntfy_topic = ?, ntfy_priority = ?, poll_interval_minutes = ?,
|
||||
include_out_of_stock = ?, min_price = ?, exclude_keywords = ?,
|
||||
listing_type = ?,
|
||||
actor_active = ?, actor_sold = ?, actor_price_compare = ?,
|
||||
use_price_comparison = ?, active = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`,
|
||||
it.Name, s.enc(it.SearchQuery), nullStr(it.URL), nullStr(it.Category),
|
||||
nullFloat(it.TargetPrice), s.enc(it.NtfyTopic), it.NtfyPriority,
|
||||
it.PollIntervalMinutes, boolToInt(it.IncludeOutOfStock),
|
||||
nullFloat(it.MinPrice), nullStr(s.enc(it.ExcludeKeywords)),
|
||||
nullStr(it.ListingType),
|
||||
nullStr(it.ActorActive), nullStr(it.ActorSold), nullStr(it.ActorPriceCompare),
|
||||
boolToInt(it.UsePriceComparison), boolToInt(it.Active),
|
||||
it.ID,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.SetItemMarketplaces(ctx, it.ID, it.Marketplaces)
|
||||
}
|
||||
|
||||
// SetItemMarketplaces replaces the marketplace list for an item. Order is
|
||||
// preserved via the `position` column.
|
||||
func (s *Store) SetItemMarketplaces(ctx context.Context, itemID int64, markets []string) error {
|
||||
tx, err := s.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM item_marketplaces WHERE item_id = ?`, itemID); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, m := range markets {
|
||||
if m == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO item_marketplaces (item_id, position, marketplace) VALUES (?, ?, ?)`,
|
||||
itemID, i, m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// getItemMarketplaces returns the ordered marketplace list for one item.
|
||||
func (s *Store) getItemMarketplaces(ctx context.Context, itemID int64) ([]string, error) {
|
||||
rows, err := s.DB.QueryContext(ctx,
|
||||
`SELECT marketplace FROM item_marketplaces WHERE item_id = ? ORDER BY position`, itemID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var m string
|
||||
if err := rows.Scan(&m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// loadMarketplacesForItems bulk-loads marketplaces for a list of items in one
|
||||
// query. Returns a map keyed by item ID.
|
||||
func (s *Store) loadMarketplacesForItems(ctx context.Context, ids []int64) (map[int64][]string, error) {
|
||||
out := make(map[int64][]string, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
placeholders := make([]string, len(ids))
|
||||
args := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
placeholders[i] = "?"
|
||||
args[i] = id
|
||||
}
|
||||
q := `SELECT item_id, marketplace FROM item_marketplaces WHERE item_id IN (` +
|
||||
strings.Join(placeholders, ",") + `) ORDER BY item_id, position`
|
||||
rows, err := s.DB.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var m string
|
||||
if err := rows.Scan(&id, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[id] = append(out[id], m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) SetItemActive(ctx context.Context, id int64, active bool) error {
|
||||
_, err := s.DB.ExecContext(ctx,
|
||||
`UPDATE items SET active = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
boolToInt(active), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteItem(ctx context.Context, id int64) error {
|
||||
_, err := s.DB.ExecContext(ctx, `DELETE FROM items WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetItem(ctx context.Context, id int64) (*models.Item, error) {
|
||||
row := s.DB.QueryRowContext(ctx, itemSelect+` WHERE id = ?`, id)
|
||||
it, err := scanItem(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
s.decryptItem(it)
|
||||
markets, err := s.getItemMarketplaces(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
it.Marketplaces = markets
|
||||
return it, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListItems(ctx context.Context) ([]models.Item, error) {
|
||||
return s.listItemsWhere(ctx, itemSelect+` ORDER BY name COLLATE NOCASE`)
|
||||
}
|
||||
|
||||
func (s *Store) ListActiveItems(ctx context.Context) ([]models.Item, error) {
|
||||
return s.listItemsWhere(ctx, itemSelect+` WHERE active = 1 ORDER BY id`)
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []models.Item
|
||||
var ids []int64
|
||||
for rows.Next() {
|
||||
it, err := scanItem(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.decryptItem(it)
|
||||
out = append(out, *it)
|
||||
ids = append(ids, it.ID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
markets, err := s.loadMarketplacesForItems(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range out {
|
||||
out[i].Marketplaces = markets[out[i].ID]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
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 {
|
||||
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()
|
||||
}
|
||||
|
||||
// UpdateItemPollResult writes best-price fields, last_polled_at, last_poll_error.
|
||||
func (s *Store) UpdateItemPollResult(ctx context.Context, id int64, best *models.Item, errMsg string) error {
|
||||
var (
|
||||
bestPrice sql.NullFloat64
|
||||
bestStore, bestURL, bestImage, bestTitle, errField sql.NullString
|
||||
)
|
||||
if best != nil {
|
||||
bestPrice = nullFloat(best.BestPrice)
|
||||
bestStore = nullStr(best.BestPriceStore)
|
||||
bestURL = nullStr(s.enc(best.BestPriceURL))
|
||||
bestImage = nullStr(s.enc(best.BestPriceImageURL))
|
||||
bestTitle = nullStr(s.enc(best.BestPriceTitle))
|
||||
}
|
||||
if errMsg != "" {
|
||||
errField = nullStr(s.enc(errMsg))
|
||||
}
|
||||
_, err := s.DB.ExecContext(ctx, `
|
||||
UPDATE items SET
|
||||
best_price = ?, best_price_store = ?, best_price_url = ?,
|
||||
best_price_image_url = ?, best_price_title = ?,
|
||||
last_polled_at = CURRENT_TIMESTAMP, last_poll_error = ?
|
||||
WHERE id = ?
|
||||
`, bestPrice, bestStore, bestURL, bestImage, bestTitle, errField, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const itemSelect = `
|
||||
SELECT id, name, search_query, url, category, target_price, ntfy_topic, ntfy_priority,
|
||||
poll_interval_minutes, include_out_of_stock, min_price, exclude_keywords,
|
||||
listing_type,
|
||||
actor_active, actor_sold, actor_price_compare, use_price_comparison,
|
||||
active, last_polled_at, last_poll_error, best_price, best_price_store,
|
||||
best_price_url, best_price_image_url, best_price_title, created_at, updated_at
|
||||
FROM items
|
||||
`
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanItem(r rowScanner) (*models.Item, error) {
|
||||
var (
|
||||
it models.Item
|
||||
searchQuery, urlS, category, listingType sql.NullString
|
||||
excludeKw sql.NullString
|
||||
actorA, actorS, actorP sql.NullString
|
||||
ntfyTopic, lastPollErr sql.NullString
|
||||
bestStore, bestURL, bestImage, bestTitle sql.NullString
|
||||
targetPrice, minPrice, bestPrice sql.NullFloat64
|
||||
includeOOS, usePC, active int
|
||||
lastPolledAt sql.NullTime
|
||||
)
|
||||
if err := r.Scan(
|
||||
&it.ID, &it.Name, &searchQuery, &urlS, &category, &targetPrice, &ntfyTopic, &it.NtfyPriority,
|
||||
&it.PollIntervalMinutes, &includeOOS, &minPrice, &excludeKw,
|
||||
&listingType,
|
||||
&actorA, &actorS, &actorP, &usePC,
|
||||
&active, &lastPolledAt, &lastPollErr, &bestPrice, &bestStore,
|
||||
&bestURL, &bestImage, &bestTitle, &it.CreatedAt, &it.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
it.ExcludeKeywords = excludeKw.String
|
||||
it.MinPrice = ptrFloat(minPrice)
|
||||
it.SearchQuery = searchQuery.String
|
||||
it.URL = urlS.String
|
||||
it.Category = category.String
|
||||
it.ListingType = listingType.String
|
||||
it.ActorActive = actorA.String
|
||||
it.ActorSold = actorS.String
|
||||
it.ActorPriceCompare = actorP.String
|
||||
it.NtfyTopic = ntfyTopic.String
|
||||
it.LastPollError = lastPollErr.String
|
||||
it.BestPriceStore = bestStore.String
|
||||
it.BestPriceURL = bestURL.String
|
||||
it.BestPriceImageURL = bestImage.String
|
||||
it.BestPriceTitle = bestTitle.String
|
||||
it.TargetPrice = ptrFloat(targetPrice)
|
||||
it.BestPrice = ptrFloat(bestPrice)
|
||||
it.IncludeOutOfStock = includeOOS != 0
|
||||
it.UsePriceComparison = usePC != 0
|
||||
it.Active = active != 0
|
||||
it.LastPolledAt = ptrTime(lastPolledAt)
|
||||
return &it, nil
|
||||
}
|
||||
|
||||
func (s *Store) decryptItem(it *models.Item) *models.Item {
|
||||
it.SearchQuery = s.dec(it.SearchQuery)
|
||||
it.ExcludeKeywords = s.dec(it.ExcludeKeywords)
|
||||
it.NtfyTopic = s.dec(it.NtfyTopic)
|
||||
it.LastPollError = s.dec(it.LastPollError)
|
||||
it.BestPriceURL = s.dec(it.BestPriceURL)
|
||||
it.BestPriceImageURL = s.dec(it.BestPriceImageURL)
|
||||
it.BestPriceTitle = s.dec(it.BestPriceTitle)
|
||||
return it
|
||||
}
|
||||
|
||||
// ============ results ============
|
||||
|
||||
func (s *Store) InsertResult(ctx context.Context, r *models.Result) (int64, error) {
|
||||
res, err := s.DB.ExecContext(ctx, `
|
||||
INSERT INTO results (item_id, title, price, currency, url, source, image_url, matched_query, alerted, found_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`,
|
||||
r.ItemID, s.enc(r.Title), nullFloat(r.Price), r.Currency,
|
||||
nullStr(r.URL), nullStr(r.Source), s.enc(r.ImageURL),
|
||||
nullStr(s.enc(r.MatchedQuery)),
|
||||
boolToInt(r.Alerted),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// ResultExists returns true if a row with this item_id and url already exists.
|
||||
// URL is stored as plaintext per agreed deviation #1, so equality works.
|
||||
func (s *Store) ResultExists(ctx context.Context, itemID int64, url string) (bool, error) {
|
||||
if url == "" {
|
||||
return false, nil
|
||||
}
|
||||
var n int
|
||||
err := s.DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM results WHERE item_id = ? AND url = ?`, itemID, url,
|
||||
).Scan(&n)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
func (s *Store) MarkResultAlerted(ctx context.Context, id int64) error {
|
||||
_, err := s.DB.ExecContext(ctx, `UPDATE results SET alerted = 1 WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
type ResultsQuery struct {
|
||||
ItemID int64 // 0 = all items
|
||||
Limit int
|
||||
Offset int
|
||||
Order string // "price_asc", "price_desc", "found_desc" (default), "found_asc"
|
||||
}
|
||||
|
||||
func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Result, error) {
|
||||
order := `found_at DESC`
|
||||
switch q.Order {
|
||||
case "price_asc":
|
||||
order = `price ASC NULLS LAST`
|
||||
case "price_desc":
|
||||
order = `price DESC NULLS LAST`
|
||||
case "found_asc":
|
||||
order = `found_at ASC`
|
||||
}
|
||||
limit := q.Limit
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
args := []any{}
|
||||
where := ""
|
||||
if q.ItemID != 0 {
|
||||
where = `WHERE item_id = ?`
|
||||
args = append(args, q.ItemID)
|
||||
}
|
||||
args = append(args, limit, q.Offset)
|
||||
rows, err := s.DB.QueryContext(ctx, fmt.Sprintf(`
|
||||
SELECT id, item_id, title, price, currency, url, source, image_url, matched_query, alerted, found_at
|
||||
FROM results %s ORDER BY %s LIMIT ? OFFSET ?
|
||||
`, where, order), args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []models.Result
|
||||
for rows.Next() {
|
||||
var (
|
||||
r models.Result
|
||||
title, urlS, source, imageS, matchQ sql.NullString
|
||||
price sql.NullFloat64
|
||||
alerted int
|
||||
)
|
||||
if err := rows.Scan(&r.ID, &r.ItemID, &title, &price, &r.Currency, &urlS, &source, &imageS, &matchQ, &alerted, &r.FoundAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Title = s.dec(title.String)
|
||||
r.URL = urlS.String
|
||||
r.Source = source.String
|
||||
r.ImageURL = s.dec(imageS.String)
|
||||
r.MatchedQuery = s.dec(matchQ.String)
|
||||
r.Price = ptrFloat(price)
|
||||
r.Alerted = alerted != 0
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) CountResults(ctx context.Context, itemID int64) (int, error) {
|
||||
var n int
|
||||
q := `SELECT COUNT(*) FROM results`
|
||||
args := []any{}
|
||||
if itemID != 0 {
|
||||
q += ` WHERE item_id = ?`
|
||||
args = append(args, itemID)
|
||||
}
|
||||
err := s.DB.QueryRowContext(ctx, q, args...).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// ============ price_history ============
|
||||
|
||||
func (s *Store) InsertPricePoint(ctx context.Context, p *models.PricePoint) error {
|
||||
if p.PolledAt.IsZero() {
|
||||
_, err := s.DB.ExecContext(ctx,
|
||||
`INSERT INTO price_history (item_id, price, store) VALUES (?, ?, ?)`,
|
||||
p.ItemID, p.Price, s.enc(p.Store))
|
||||
return err
|
||||
}
|
||||
_, err := s.DB.ExecContext(ctx,
|
||||
`INSERT INTO price_history (item_id, price, store, polled_at) VALUES (?, ?, ?, ?)`,
|
||||
p.ItemID, p.Price, s.enc(p.Store), p.PolledAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListPriceHistory(ctx context.Context, itemID int64) ([]models.PricePoint, error) {
|
||||
rows, err := s.DB.QueryContext(ctx,
|
||||
`SELECT id, item_id, price, store, polled_at FROM price_history WHERE item_id = ? ORDER BY polled_at ASC`,
|
||||
itemID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []models.PricePoint
|
||||
for rows.Next() {
|
||||
var p models.PricePoint
|
||||
var store sql.NullString
|
||||
if err := rows.Scan(&p.ID, &p.ItemID, &p.Price, &store, &p.PolledAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Store = s.dec(store.String)
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ============ stats ============
|
||||
|
||||
type DashboardStats struct {
|
||||
TotalItems int
|
||||
ActiveItems int
|
||||
ResultsToday int
|
||||
AlertsToday int
|
||||
PotentialSpend float64
|
||||
PricedItemCount int
|
||||
UnpricedCount int
|
||||
MoneySaved float64
|
||||
SavedItemCount int
|
||||
}
|
||||
|
||||
func (s *Store) GetDashboardStats(ctx context.Context) (*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,
|
||||
}
|
||||
for q, dst := range queries {
|
||||
if err := s.DB.QueryRowContext(ctx, q).Scan(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 {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
GROUP BY i.id
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var bp, avg float64
|
||||
if err := rows.Scan(&bp, &avg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if bp < avg {
|
||||
d.MoneySaved += avg - bp
|
||||
d.SavedItemCount++
|
||||
}
|
||||
}
|
||||
return d, rows.Err()
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
99
internal/db/schema.sql
Normal file
99
internal/db/schema.sql
Normal file
@@ -0,0 +1,99 @@
|
||||
PRAGMA journal_mode=WAL;
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
search_query TEXT,
|
||||
url TEXT,
|
||||
category TEXT,
|
||||
target_price REAL,
|
||||
ntfy_topic TEXT NOT NULL,
|
||||
ntfy_priority TEXT DEFAULT 'default',
|
||||
poll_interval_minutes INTEGER DEFAULT 60,
|
||||
include_out_of_stock INTEGER DEFAULT 0,
|
||||
min_price REAL,
|
||||
exclude_keywords TEXT,
|
||||
listing_type TEXT,
|
||||
actor_active TEXT,
|
||||
actor_sold TEXT,
|
||||
actor_price_compare TEXT,
|
||||
use_price_comparison INTEGER DEFAULT 0,
|
||||
active INTEGER DEFAULT 1,
|
||||
last_polled_at DATETIME,
|
||||
last_poll_error TEXT,
|
||||
best_price REAL,
|
||||
best_price_store TEXT,
|
||||
best_price_url TEXT,
|
||||
best_price_image_url TEXT,
|
||||
best_price_title TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_items_active ON items(active);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS item_marketplaces (
|
||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL,
|
||||
marketplace TEXT NOT NULL,
|
||||
PRIMARY KEY (item_id, position)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_item_marketplaces_item ON item_marketplaces(item_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS results (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
title TEXT,
|
||||
price REAL,
|
||||
currency TEXT NOT NULL,
|
||||
url TEXT,
|
||||
source TEXT,
|
||||
image_url TEXT,
|
||||
matched_query TEXT,
|
||||
alerted INTEGER DEFAULT 0,
|
||||
found_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_results_item ON results(item_id, found_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_results_dedup ON results(item_id, url);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS price_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
price REAL NOT NULL,
|
||||
store TEXT,
|
||||
polled_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_price_history_item ON price_history(item_id, polled_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO settings (key, value) VALUES
|
||||
('apify_api_key', ''),
|
||||
('ntfy_base_url', ''),
|
||||
('ntfy_default_topic', 'veola'),
|
||||
('global_poll_interval_minutes', '60'),
|
||||
('match_confidence_threshold', '0.6');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
data BLOB NOT NULL,
|
||||
expiry REAL NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expiry ON sessions(expiry);
|
||||
113
internal/handlers/auth.go
Normal file
113
internal/handlers/auth.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"veola/internal/auth"
|
||||
"veola/internal/models"
|
||||
"veola/templates"
|
||||
)
|
||||
|
||||
func (a *App) GetLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if auth.CurrentUserFromRequest(r) != nil {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
render(w, r, templates.Login(templates.LoginData{
|
||||
Page: a.page(r, "Sign in", ""),
|
||||
}))
|
||||
}
|
||||
|
||||
func (a *App) PostLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
password := r.PostFormValue("password")
|
||||
u, err := a.Store.GetUserByUsername(r.Context(), username)
|
||||
if err != nil || u == nil || !auth.CheckPassword(u.PasswordHash, password) {
|
||||
render(w, r, templates.Login(templates.LoginData{
|
||||
Page: a.page(r, "Sign in", ""),
|
||||
Error: "Invalid username or password",
|
||||
Username: username,
|
||||
}))
|
||||
return
|
||||
}
|
||||
if err := a.Auth.LogIn(r.Context(), u.ID); err != nil {
|
||||
http.Error(w, "session error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) PostLogout(w http.ResponseWriter, r *http.Request) {
|
||||
_ = a.Auth.LogOut(r.Context())
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) GetSetup(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := a.Store.UserCount(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
render(w, r, templates.Setup(templates.SetupData{
|
||||
Page: a.page(r, "Setup", ""),
|
||||
}))
|
||||
}
|
||||
|
||||
func (a *App) PostSetup(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := a.Store.UserCount(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
password := r.PostFormValue("password")
|
||||
confirm := r.PostFormValue("password_confirm")
|
||||
errMsg := ""
|
||||
switch {
|
||||
case username == "":
|
||||
errMsg = "Username is required"
|
||||
case len(password) < auth.MinPasswordLen:
|
||||
errMsg = "Password must be at least 12 characters"
|
||||
case password != confirm:
|
||||
errMsg = "Passwords do not match"
|
||||
}
|
||||
if errMsg != "" {
|
||||
render(w, r, templates.Setup(templates.SetupData{
|
||||
Page: a.page(r, "Setup", ""),
|
||||
Error: errMsg,
|
||||
Username: username,
|
||||
}))
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
http.Error(w, "hash error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := a.Store.CreateUser(r.Context(), username, hash, models.RoleAdmin); err != nil {
|
||||
render(w, r, templates.Setup(templates.SetupData{
|
||||
Page: a.page(r, "Setup", ""),
|
||||
Error: "Could not create user: " + err.Error(),
|
||||
Username: username,
|
||||
}))
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
106
internal/handlers/dashboard.go
Normal file
106
internal/handlers/dashboard.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"veola/internal/db"
|
||||
"veola/templates"
|
||||
)
|
||||
|
||||
func (a *App) GetDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
d, err := a.dashboardData(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
render(w, r, templates.Dashboard(d))
|
||||
}
|
||||
|
||||
func (a *App) GetDashboardRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
d, err := a.dashboardData(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Render only the inner block by reusing the full body component; the
|
||||
// outer hx-swap="outerHTML" replaces the same wrapper. The full Dashboard
|
||||
// template is overkill but keeps a single source of truth.
|
||||
render(w, r, templates.Dashboard(d))
|
||||
}
|
||||
|
||||
func (a *App) dashboardData(r *http.Request) (templates.DashboardData, error) {
|
||||
stats, err := a.Store.GetDashboardStats(r.Context())
|
||||
if err != nil {
|
||||
return templates.DashboardData{}, err
|
||||
}
|
||||
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{Limit: 20})
|
||||
if err != nil {
|
||||
return templates.DashboardData{}, err
|
||||
}
|
||||
itemNames := map[int64]string{}
|
||||
all, _ := a.Store.ListItems(r.Context())
|
||||
for _, it := range all {
|
||||
itemNames[it.ID] = it.Name
|
||||
}
|
||||
rrs := make([]templates.ResultRow, 0, len(results))
|
||||
for _, r := range results {
|
||||
rrs = append(rrs, templates.ResultRow{
|
||||
ItemID: r.ItemID,
|
||||
ItemName: itemNames[r.ItemID],
|
||||
Title: r.Title,
|
||||
Price: r.Price,
|
||||
Currency: r.Currency,
|
||||
Source: r.Source,
|
||||
URL: r.URL,
|
||||
FoundAt: r.FoundAt,
|
||||
Alerted: r.Alerted,
|
||||
})
|
||||
}
|
||||
alerts, err := alertsRecent(a, r, itemNames)
|
||||
if err != nil {
|
||||
return templates.DashboardData{}, err
|
||||
}
|
||||
return templates.DashboardData{
|
||||
Page: a.page(r, "Dashboard", "dashboard"),
|
||||
Stats: stats,
|
||||
RecentResults: rrs,
|
||||
RecentAlerts: alerts,
|
||||
}, 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
|
||||
`)
|
||||
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 = append(out, templates.AlertRow{
|
||||
ItemName: itemNames[itemID],
|
||||
Price: p,
|
||||
Currency: currency,
|
||||
FoundAt: foundAt,
|
||||
})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
146
internal/handlers/handlers.go
Normal file
146
internal/handlers/handlers.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Package handlers wires HTTP routes for Veola. Each file in the package owns
|
||||
// a related cluster of routes; this file holds the shared App container and
|
||||
// helper functions used across all handlers.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"veola/internal/apify"
|
||||
"veola/internal/auth"
|
||||
"veola/internal/config"
|
||||
"veola/internal/db"
|
||||
"veola/internal/ntfy"
|
||||
"veola/internal/scheduler"
|
||||
"veola/templates"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
Cfg *config.Config
|
||||
Store *db.Store
|
||||
Auth *auth.Manager
|
||||
Apify *apify.Client
|
||||
Ntfy *ntfy.Client
|
||||
Scheduler *scheduler.Scheduler
|
||||
Preview *PreviewCache
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, store *db.Store, am *auth.Manager, ap *apify.Client, nt *ntfy.Client, sc *scheduler.Scheduler) *App {
|
||||
return &App{
|
||||
Cfg: cfg, Store: store, Auth: am,
|
||||
Apify: ap, Ntfy: nt, Scheduler: sc,
|
||||
Preview: NewPreviewCache(10 * time.Minute),
|
||||
}
|
||||
}
|
||||
|
||||
// Routes returns the chi router with everything wired up.
|
||||
func (a *App) Routes() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
fs := http.FileServer(http.Dir("./static"))
|
||||
r.Handle("/static/*", http.StripPrefix("/static/", fs))
|
||||
|
||||
// All other routes pass through session loading + setup gate.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(a.Auth.Sessions.LoadAndSave)
|
||||
r.Use(a.Auth.LoadUser)
|
||||
r.Use(a.setupGate)
|
||||
|
||||
// Public auth pages.
|
||||
r.Get("/login", a.GetLogin)
|
||||
r.With(a.Auth.CSRFProtect).Post("/login", a.PostLogin)
|
||||
r.Get("/setup", a.GetSetup)
|
||||
r.With(a.Auth.CSRFProtect).Post("/setup", a.PostSetup)
|
||||
|
||||
// Authenticated section.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(a.Auth.RequireAuth)
|
||||
r.With(a.Auth.CSRFProtect).Post("/logout", a.PostLogout)
|
||||
|
||||
r.Get("/", a.GetDashboard)
|
||||
r.Get("/dashboard/refresh", a.GetDashboardRefresh)
|
||||
|
||||
r.Get("/items", a.GetItems)
|
||||
r.Get("/items/new", a.GetNewItem)
|
||||
r.With(a.Auth.CSRFProtect).Post("/items/preview", a.PostPreview)
|
||||
r.With(a.Auth.CSRFProtect).Post("/items", a.PostCreateItem)
|
||||
r.Get("/items/{id}/edit", a.GetEditItem)
|
||||
r.With(a.Auth.CSRFProtect).Post("/items/{id}", a.PostUpdateItem)
|
||||
r.With(a.Auth.CSRFProtect).Post("/items/{id}/toggle", a.PostToggleItem)
|
||||
r.With(a.Auth.CSRFProtect).Post("/items/{id}/delete", a.PostDeleteItem)
|
||||
r.With(a.Auth.CSRFProtect).Post("/items/{id}/run", a.PostRunItem)
|
||||
r.Get("/items/{id}/error", a.GetItemError)
|
||||
r.Get("/items/{id}/results", a.GetItemResults)
|
||||
|
||||
r.Get("/results", a.GetGlobalResults)
|
||||
|
||||
r.Get("/settings", a.GetSettings)
|
||||
r.With(a.Auth.CSRFProtect).Post("/settings", a.PostSettings)
|
||||
r.With(a.Auth.CSRFProtect).Post("/settings/password", a.PostPasswordChange)
|
||||
r.With(a.Auth.CSRFProtect).Post("/settings/test-ntfy", a.PostTestNtfy)
|
||||
r.With(a.Auth.CSRFProtect).Post("/settings/test-apify", a.PostTestApify)
|
||||
r.With(a.Auth.CSRFProtect, 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)
|
||||
})
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
// setupGate redirects every request to /setup if no users exist.
|
||||
func (a *App) setupGate(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/setup" || isStaticPath(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
n, err := a.Store.UserCount(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
http.Redirect(w, r, "/setup", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// Once at least one user exists, /setup is a 404.
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func isStaticPath(p string) bool {
|
||||
return len(p) >= 8 && p[:8] == "/static/"
|
||||
}
|
||||
|
||||
func (a *App) page(r *http.Request, title, active string) templates.Page {
|
||||
return templates.Page{
|
||||
Title: title,
|
||||
Active: active,
|
||||
CSRFToken: a.Auth.CSRFToken(r.Context()),
|
||||
CurrentUser: auth.CurrentUserFromRequest(r),
|
||||
}
|
||||
}
|
||||
|
||||
func render(w http.ResponseWriter, r *http.Request, c templ.Component) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := c.Render(r.Context(), w); err != nil {
|
||||
slog.Error("render failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func parseInt64(s string) int64 {
|
||||
n, _ := strconv.ParseInt(s, 10, 64)
|
||||
return n
|
||||
}
|
||||
|
||||
func intParam(r *http.Request, key string) int64 {
|
||||
return parseInt64(chi.URLParam(r, key))
|
||||
}
|
||||
|
||||
func ctxBg() context.Context { return context.Background() }
|
||||
453
internal/handlers/items.go
Normal file
453
internal/handlers/items.go
Normal file
@@ -0,0 +1,453 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"veola/internal/apify"
|
||||
"veola/internal/models"
|
||||
"veola/internal/scheduler"
|
||||
"veola/templates"
|
||||
)
|
||||
|
||||
func (a *App) GetItems(w http.ResponseWriter, r *http.Request) {
|
||||
cat := r.URL.Query().Get("category")
|
||||
all, err := a.Store.ListItems(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var items []models.Item
|
||||
for _, it := range all {
|
||||
if cat == "" || it.Category == cat {
|
||||
items = append(items, it)
|
||||
}
|
||||
}
|
||||
cats, _ := a.Store.ListCategories(r.Context())
|
||||
render(w, r, templates.Items(templates.ItemsData{
|
||||
Page: a.page(r, "Items", "items"),
|
||||
Items: items,
|
||||
Categories: cats,
|
||||
SelectedCategory: cat,
|
||||
}))
|
||||
}
|
||||
|
||||
func (a *App) GetNewItem(w http.ResponseWriter, r *http.Request) {
|
||||
cats, _ := a.Store.ListCategories(r.Context())
|
||||
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,
|
||||
Marketplaces: []string{"ebay.com"},
|
||||
ListingType: "all",
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
func (a *App) GetEditItem(w http.ResponseWriter, r *http.Request) {
|
||||
id := intParam(r, "id")
|
||||
it, err := a.Store.GetItem(r.Context(), id)
|
||||
if err != nil || it == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
cats, _ := a.Store.ListCategories(r.Context())
|
||||
render(w, r, templates.ItemForm(templates.ItemFormData{
|
||||
Page: a.page(r, "Edit "+it.Name, "items"),
|
||||
IsEdit: true,
|
||||
Item: *it,
|
||||
Categories: cats,
|
||||
}))
|
||||
}
|
||||
|
||||
// parseItemForm pulls form fields into a models.Item plus a list of validation
|
||||
// errors. Used by preview, create, and update.
|
||||
func parseItemForm(r *http.Request) (models.Item, []string) {
|
||||
var it models.Item
|
||||
var errs []string
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return it, []string{"could not parse form"}
|
||||
}
|
||||
it.Name = strings.TrimSpace(r.PostFormValue("name"))
|
||||
it.SearchQuery = strings.Join(models.SplitList(r.PostFormValue("search_query"), 10), "\n")
|
||||
it.ExcludeKeywords = strings.Join(models.SplitList(r.PostFormValue("exclude_keywords"), 20), "\n")
|
||||
it.URL = strings.TrimSpace(r.PostFormValue("url"))
|
||||
if newCat := strings.TrimSpace(r.PostFormValue("category_new")); newCat != "" {
|
||||
it.Category = newCat
|
||||
} else {
|
||||
it.Category = strings.TrimSpace(r.PostFormValue("category"))
|
||||
}
|
||||
it.NtfyTopic = strings.TrimSpace(r.PostFormValue("ntfy_topic"))
|
||||
it.NtfyPriority = strings.TrimSpace(r.PostFormValue("ntfy_priority"))
|
||||
if it.NtfyPriority == "" {
|
||||
it.NtfyPriority = "default"
|
||||
}
|
||||
it.Marketplaces = collectMarketplaces(r.PostForm["marketplace"], r.PostFormValue("marketplace_custom"))
|
||||
it.ListingType = strings.TrimSpace(r.PostFormValue("listing_type"))
|
||||
it.ActorActive = strings.TrimSpace(r.PostFormValue("actor_active"))
|
||||
it.ActorSold = strings.TrimSpace(r.PostFormValue("actor_sold"))
|
||||
it.ActorPriceCompare = strings.TrimSpace(r.PostFormValue("actor_price_compare"))
|
||||
it.IncludeOutOfStock = r.PostFormValue("include_out_of_stock") == "1"
|
||||
it.UsePriceComparison = r.PostFormValue("use_price_comparison") == "1"
|
||||
it.Active = true
|
||||
|
||||
if tp := strings.TrimSpace(r.PostFormValue("target_price")); tp != "" {
|
||||
if v, err := strconv.ParseFloat(tp, 64); err == nil && v >= 0 {
|
||||
it.TargetPrice = &v
|
||||
}
|
||||
}
|
||||
if mp := strings.TrimSpace(r.PostFormValue("min_price")); mp != "" {
|
||||
if v, err := strconv.ParseFloat(mp, 64); err == nil && v >= 0 {
|
||||
it.MinPrice = &v
|
||||
}
|
||||
}
|
||||
if pi := strings.TrimSpace(r.PostFormValue("poll_interval_minutes")); pi != "" {
|
||||
if v, err := strconv.Atoi(pi); err == nil && v > 0 {
|
||||
it.PollIntervalMinutes = v
|
||||
}
|
||||
}
|
||||
if it.PollIntervalMinutes == 0 {
|
||||
it.PollIntervalMinutes = 60
|
||||
}
|
||||
|
||||
if it.Name == "" {
|
||||
errs = append(errs, "name is required")
|
||||
}
|
||||
if it.SearchQuery == "" && it.URL == "" {
|
||||
errs = append(errs, "either search query or product URL is required")
|
||||
}
|
||||
if it.NtfyTopic == "" {
|
||||
// Default to a slug of the name.
|
||||
it.NtfyTopic = slugify(it.Name)
|
||||
}
|
||||
return it, errs
|
||||
}
|
||||
|
||||
// collectMarketplaces dedupes the checkbox values + custom CSV input into an
|
||||
// ordered slice. Checkbox order first, then custom entries in the order the
|
||||
// user typed them.
|
||||
func collectMarketplaces(checked []string, custom string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
add := func(v string) {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" || seen[v] {
|
||||
return
|
||||
}
|
||||
seen[v] = true
|
||||
out = append(out, v)
|
||||
}
|
||||
for _, v := range checked {
|
||||
add(v)
|
||||
}
|
||||
for _, v := range strings.Split(custom, ",") {
|
||||
add(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func slugify(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == ' ', r == '_', r == '-':
|
||||
b.WriteRune('-')
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
if out == "" {
|
||||
return "veola-item"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) PostPreview(w http.ResponseWriter, r *http.Request) {
|
||||
it, errs := parseItemForm(r)
|
||||
if len(errs) > 0 {
|
||||
render(w, r, templates.ItemPreview(templates.PreviewData{
|
||||
CSRFToken: a.Auth.CSRFToken(r.Context()),
|
||||
Form: formValuesFromItem(it, r),
|
||||
Error: strings.Join(errs, "; "),
|
||||
}))
|
||||
return
|
||||
}
|
||||
results, source, cached, err := a.runPreview(r.Context(), it)
|
||||
if err != nil {
|
||||
render(w, r, templates.ItemPreview(templates.PreviewData{
|
||||
CSRFToken: a.Auth.CSRFToken(r.Context()),
|
||||
Form: formValuesFromItem(it, r),
|
||||
Error: err.Error(),
|
||||
}))
|
||||
return
|
||||
}
|
||||
results = scheduler.FilterResults(results, a.Cfg.Scheduler.MatchConfidenceThreshold, it.IncludeOutOfStock)
|
||||
results = scheduler.ApplyItemFilters(results, it.MinPrice, it.ExcludeKeywordsList())
|
||||
if len(results) == 0 {
|
||||
render(w, r, templates.ItemPreview(templates.PreviewData{
|
||||
CSRFToken: a.Auth.CSRFToken(r.Context()),
|
||||
Form: formValuesFromItem(it, r),
|
||||
Empty: true,
|
||||
}))
|
||||
return
|
||||
}
|
||||
bestIdx := scheduler.PickBest(results)
|
||||
minP, maxP := results[0].Price, results[0].Price
|
||||
stores := map[string]struct{}{}
|
||||
cur := results[0].Currency
|
||||
for _, r := range results {
|
||||
if r.Price < minP {
|
||||
minP = r.Price
|
||||
}
|
||||
if r.Price > maxP {
|
||||
maxP = r.Price
|
||||
}
|
||||
stores[r.Store] = struct{}{}
|
||||
}
|
||||
render(w, r, templates.ItemPreview(templates.PreviewData{
|
||||
CSRFToken: a.Auth.CSRFToken(r.Context()),
|
||||
Form: formValuesFromItem(it, r),
|
||||
Results: results,
|
||||
BestIndex: bestIdx,
|
||||
MinPrice: minP,
|
||||
MaxPrice: maxP,
|
||||
StoreCount: len(stores),
|
||||
Cached: cached,
|
||||
Currency: cur,
|
||||
}))
|
||||
_ = source
|
||||
}
|
||||
|
||||
func (a *App) runPreview(ctx context.Context, it models.Item) ([]apify.UnifiedResult, string, bool, error) {
|
||||
plans := a.Scheduler.BuildPreviewInputs(it)
|
||||
if len(plans) == 0 {
|
||||
return nil, "", false, fmt.Errorf("no actor configured for this item")
|
||||
}
|
||||
previewMarket := ""
|
||||
if len(it.Marketplaces) > 0 {
|
||||
previewMarket = it.Marketplaces[0]
|
||||
}
|
||||
queries := it.SearchQueries()
|
||||
sortedQ := make([]string, len(queries))
|
||||
copy(sortedQ, queries)
|
||||
sort.Strings(sortedQ)
|
||||
actorIDs := make([]string, 0, len(plans))
|
||||
for _, p := range plans {
|
||||
actorIDs = append(actorIDs, p.ActorID())
|
||||
}
|
||||
sort.Strings(actorIDs)
|
||||
key := previewKey{
|
||||
Queries: strings.Join(sortedQ, "\n"),
|
||||
URL: it.URL,
|
||||
Marketplace: previewMarket,
|
||||
ListingType: it.ListingType,
|
||||
ActorIDs: strings.Join(actorIDs, ","),
|
||||
MaxResults: 30,
|
||||
}
|
||||
if cached, src, ok := a.Preview.Get(key); ok {
|
||||
return cached, src, true, nil
|
||||
}
|
||||
var merged []apify.UnifiedResult
|
||||
primarySource := ""
|
||||
for _, p := range plans {
|
||||
actorID := p.ActorID()
|
||||
if actorID == "" {
|
||||
continue
|
||||
}
|
||||
raw, err := a.Apify.Run(ctx, actorID, p.Input())
|
||||
if err != nil {
|
||||
slog.Warn("preview run failed", "actor", actorID, "query", p.Query(), "err", err)
|
||||
continue
|
||||
}
|
||||
decoded, _ := apify.Decode(raw, p.Source())
|
||||
for i := range decoded {
|
||||
decoded[i].MatchedQuery = p.Query()
|
||||
}
|
||||
usable := 0
|
||||
for _, r := range decoded {
|
||||
if r.URL != "" && r.Price > 0 {
|
||||
usable++
|
||||
}
|
||||
}
|
||||
slog.Info("preview decoded",
|
||||
"marketplace", previewMarket,
|
||||
"actor", actorID,
|
||||
"query", p.Query(),
|
||||
"raw", len(raw),
|
||||
"decoded", len(decoded),
|
||||
"usable", usable,
|
||||
)
|
||||
if usable == 0 && len(raw) > 0 {
|
||||
var sample map[string]any
|
||||
if err := json.Unmarshal(raw[0], &sample); err == nil {
|
||||
ks := make([]string, 0, len(sample))
|
||||
for k := range sample {
|
||||
ks = append(ks, k)
|
||||
}
|
||||
slog.Warn("preview decoded zero usable rows; raw item keys",
|
||||
"actor", actorID,
|
||||
"keys", ks,
|
||||
)
|
||||
}
|
||||
}
|
||||
merged = append(merged, decoded...)
|
||||
if primarySource == "" {
|
||||
primarySource = p.Source()
|
||||
}
|
||||
}
|
||||
merged = scheduler.DedupByURL(merged)
|
||||
a.Preview.Put(key, merged, primarySource)
|
||||
return merged, primarySource, false, nil
|
||||
}
|
||||
|
||||
func formValuesFromItem(it models.Item, r *http.Request) templates.FormValues {
|
||||
tp := ""
|
||||
if it.TargetPrice != nil {
|
||||
tp = fmt.Sprintf("%.2f", *it.TargetPrice)
|
||||
}
|
||||
mp := ""
|
||||
if it.MinPrice != nil {
|
||||
mp = fmt.Sprintf("%.2f", *it.MinPrice)
|
||||
}
|
||||
return templates.FormValues{
|
||||
Name: it.Name,
|
||||
SearchQuery: it.SearchQuery,
|
||||
URL: it.URL,
|
||||
Category: it.Category,
|
||||
TargetPrice: tp,
|
||||
MinPrice: mp,
|
||||
ExcludeKeywords: it.ExcludeKeywords,
|
||||
NtfyTopic: it.NtfyTopic,
|
||||
NtfyPriority: it.NtfyPriority,
|
||||
PollIntervalMinutes: fmt.Sprintf("%d", it.PollIntervalMinutes),
|
||||
IncludeOutOfStock: it.IncludeOutOfStock,
|
||||
Marketplaces: it.Marketplaces,
|
||||
ListingType: it.ListingType,
|
||||
ActorActive: it.ActorActive,
|
||||
ActorSold: it.ActorSold,
|
||||
ActorPriceCompare: it.ActorPriceCompare,
|
||||
UsePriceComparison: it.UsePriceComparison,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) PostCreateItem(w http.ResponseWriter, r *http.Request) {
|
||||
it, errs := parseItemForm(r)
|
||||
if len(errs) > 0 {
|
||||
http.Error(w, strings.Join(errs, "; "), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
id, err := a.Store.CreateItem(r.Context(), &it)
|
||||
if err != nil {
|
||||
http.Error(w, "could not save item: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
it.ID = id
|
||||
a.Scheduler.SyncItem(it)
|
||||
|
||||
go func() {
|
||||
bg := context.Background()
|
||||
fresh, err := a.Store.GetItem(bg, id)
|
||||
if err != nil || fresh == nil {
|
||||
return
|
||||
}
|
||||
a.Scheduler.SeedSoldHistory(bg, *fresh)
|
||||
a.Scheduler.RunPoll(bg, *fresh)
|
||||
}()
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/items/%d/results", id), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) PostUpdateItem(w http.ResponseWriter, r *http.Request) {
|
||||
id := intParam(r, "id")
|
||||
existing, err := a.Store.GetItem(r.Context(), 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())
|
||||
updated.ID = id
|
||||
render(w, r, templates.ItemForm(templates.ItemFormData{
|
||||
Page: a.page(r, "Edit "+updated.Name, "items"),
|
||||
IsEdit: true,
|
||||
Item: updated,
|
||||
Errors: errs,
|
||||
Categories: cats,
|
||||
}))
|
||||
return
|
||||
}
|
||||
updated.ID = id
|
||||
updated.Active = existing.Active
|
||||
if err := a.Store.UpdateItem(r.Context(), &updated); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.Scheduler.SyncItem(updated)
|
||||
http.Redirect(w, r, "/items", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) PostToggleItem(w http.ResponseWriter, r *http.Request) {
|
||||
id := intParam(r, "id")
|
||||
it, err := a.Store.GetItem(r.Context(), id)
|
||||
if err != nil || it == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
it.Active = !it.Active
|
||||
if err := a.Store.SetItemActive(r.Context(), id, it.Active); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.Scheduler.SyncItem(*it)
|
||||
render(w, r, templates.ItemRow(*it, a.Auth.CSRFToken(r.Context())))
|
||||
}
|
||||
|
||||
func (a *App) PostDeleteItem(w http.ResponseWriter, r *http.Request) {
|
||||
id := intParam(r, "id")
|
||||
if err := a.Store.DeleteItem(r.Context(), id); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.Scheduler.RemoveItem(id)
|
||||
render(w, r, templates.EmptyRow())
|
||||
}
|
||||
|
||||
func (a *App) PostRunItem(w http.ResponseWriter, r *http.Request) {
|
||||
id := intParam(r, "id")
|
||||
it, err := a.Store.GetItem(r.Context(), id)
|
||||
if err != nil || it == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
go a.Scheduler.RunPoll(context.Background(), *it)
|
||||
// Re-render the row immediately so HTMX has something to swap in.
|
||||
render(w, r, templates.ItemRow(*it, a.Auth.CSRFToken(r.Context())))
|
||||
}
|
||||
|
||||
func (a *App) GetItemError(w http.ResponseWriter, r *http.Request) {
|
||||
id := intParam(r, "id")
|
||||
it, err := a.Store.GetItem(r.Context(), id)
|
||||
if err != nil || it == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, "<span>%s</span>", htmlEscape(it.LastPollError))
|
||||
}
|
||||
|
||||
func htmlEscape(s string) string {
|
||||
r := strings.NewReplacer("&", "&", "<", "<", ">", ">", "\"", """)
|
||||
return r.Replace(s)
|
||||
}
|
||||
66
internal/handlers/preview_cache.go
Normal file
66
internal/handlers/preview_cache.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"veola/internal/apify"
|
||||
)
|
||||
|
||||
// previewKey caches the *raw* apify result set (post-decode, post-merge,
|
||||
// pre-filter). Filters like min_price and exclude_keywords are applied after
|
||||
// the cache lookup so the operator can iterate on them without burning credits.
|
||||
type previewKey struct {
|
||||
Queries, URL, Marketplace, ListingType, ActorIDs string
|
||||
MaxResults int
|
||||
}
|
||||
|
||||
type previewEntry struct {
|
||||
results []apify.UnifiedResult
|
||||
source string
|
||||
stored time.Time
|
||||
}
|
||||
|
||||
type PreviewCache struct {
|
||||
ttl time.Duration
|
||||
mu sync.Mutex
|
||||
entries map[previewKey]previewEntry
|
||||
}
|
||||
|
||||
func NewPreviewCache(ttl time.Duration) *PreviewCache {
|
||||
return &PreviewCache{
|
||||
ttl: ttl,
|
||||
entries: make(map[previewKey]previewEntry),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *PreviewCache) Get(k previewKey) ([]apify.UnifiedResult, string, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
e, ok := c.entries[k]
|
||||
if !ok {
|
||||
return nil, "", false
|
||||
}
|
||||
if time.Since(e.stored) > c.ttl {
|
||||
delete(c.entries, k)
|
||||
return nil, "", false
|
||||
}
|
||||
return e.results, e.source, true
|
||||
}
|
||||
|
||||
func (c *PreviewCache) Put(k previewKey, results []apify.UnifiedResult, source string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.entries[k] = previewEntry{results: results, source: source, stored: time.Now()}
|
||||
if len(c.entries) > 64 {
|
||||
c.evictExpired()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *PreviewCache) evictExpired() {
|
||||
for k, e := range c.entries {
|
||||
if time.Since(e.stored) > c.ttl {
|
||||
delete(c.entries, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
145
internal/handlers/results.go
Normal file
145
internal/handlers/results.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"veola/internal/db"
|
||||
"veola/internal/models"
|
||||
"veola/internal/scheduler"
|
||||
"veola/templates"
|
||||
)
|
||||
|
||||
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)
|
||||
if err != nil || it == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
order := r.URL.Query().Get("order")
|
||||
if order == "" {
|
||||
order = "found_desc"
|
||||
}
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
total, err := a.Store.CountResults(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
totalPages := (total + resultsPerPage - 1) / resultsPerPage
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
if page > totalPages {
|
||||
page = totalPages
|
||||
}
|
||||
|
||||
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{
|
||||
ItemID: id,
|
||||
Limit: resultsPerPage,
|
||||
Offset: (page - 1) * resultsPerPage,
|
||||
Order: order,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
history, err := a.Store.ListPriceHistory(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
badge := scheduler.PickBadge(*it, history, time.Now())
|
||||
chart := buildChartJSON(history)
|
||||
|
||||
render(w, r, templates.ItemResults(templates.ItemResultsData{
|
||||
Page: a.page(r, it.Name, "items"),
|
||||
Item: *it,
|
||||
Badge: badge,
|
||||
History: history,
|
||||
Results: results,
|
||||
Page_: page,
|
||||
TotalPages: totalPages,
|
||||
Order: order,
|
||||
HistoryChartJSON: chart,
|
||||
}))
|
||||
}
|
||||
|
||||
func buildChartJSON(history []models.PricePoint) string {
|
||||
c := templates.ChartJSON{
|
||||
Labels: make([]string, 0, len(history)),
|
||||
Points: make([]float64, 0, len(history)),
|
||||
}
|
||||
for _, p := range history {
|
||||
c.Labels = append(c.Labels, p.PolledAt.Format("2006-01-02"))
|
||||
c.Points = append(c.Points, p.Price)
|
||||
}
|
||||
return templates.MustChartJSON(c)
|
||||
}
|
||||
|
||||
func (a *App) GetGlobalResults(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
itemID, _ := strconv.ParseInt(q.Get("item_id"), 10, 64)
|
||||
from := strings.TrimSpace(q.Get("from"))
|
||||
to := strings.TrimSpace(q.Get("to"))
|
||||
|
||||
items, err := a.Store.ListItems(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
names := make(map[int64]string, len(items))
|
||||
for _, it := range items {
|
||||
names[it.ID] = it.Name
|
||||
}
|
||||
|
||||
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{
|
||||
ItemID: itemID,
|
||||
Limit: 200,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
fromT, _ := time.Parse("2006-01-02", from)
|
||||
toT, _ := time.Parse("2006-01-02", to)
|
||||
if !toT.IsZero() {
|
||||
toT = toT.Add(24 * time.Hour)
|
||||
}
|
||||
|
||||
rows := make([]templates.ItemResultRow, 0, len(results))
|
||||
for _, res := range results {
|
||||
if !fromT.IsZero() && res.FoundAt.Before(fromT) {
|
||||
continue
|
||||
}
|
||||
if !toT.IsZero() && !res.FoundAt.Before(toT) {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, templates.ItemResultRow{
|
||||
Result: res,
|
||||
ItemName: names[res.ItemID],
|
||||
})
|
||||
}
|
||||
|
||||
render(w, r, templates.GlobalResults(templates.GlobalResultsData{
|
||||
Page: a.page(r, "Results", "results"),
|
||||
Items: items,
|
||||
Results: rows,
|
||||
ItemID: itemID,
|
||||
From: from,
|
||||
To: to,
|
||||
}))
|
||||
}
|
||||
195
internal/handlers/settings.go
Normal file
195
internal/handlers/settings.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"veola/internal/apify"
|
||||
"veola/internal/auth"
|
||||
"veola/internal/models"
|
||||
"veola/internal/ntfy"
|
||||
"veola/templates"
|
||||
)
|
||||
|
||||
var settingsKeys = []string{
|
||||
"apify_api_key",
|
||||
"ntfy_base_url",
|
||||
"ntfy_default_topic",
|
||||
"ntfy_token",
|
||||
"global_poll_interval_minutes",
|
||||
"match_confidence_threshold",
|
||||
}
|
||||
|
||||
func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
|
||||
values, err := a.Store.GetAllSettings(r.Context())
|
||||
if err != nil {
|
||||
return templates.SettingsData{}, err
|
||||
}
|
||||
if values == nil {
|
||||
values = map[string]string{}
|
||||
}
|
||||
users, _ := a.Store.ListUsers(r.Context())
|
||||
cur := auth.CurrentUserFromRequest(r)
|
||||
return templates.SettingsData{
|
||||
Page: a.page(r, "Settings", "settings"),
|
||||
Values: values,
|
||||
IsAdmin: cur != nil && cur.Role == models.RoleAdmin,
|
||||
Users: users,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) GetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
d, err := a.settingsData(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
render(w, r, templates.Settings(d))
|
||||
}
|
||||
|
||||
func (a *App) PostSettings(w http.ResponseWriter, r *http.Request) {
|
||||
cur := auth.CurrentUserFromRequest(r)
|
||||
if cur == nil || cur.Role != models.RoleAdmin {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
for _, k := range settingsKeys {
|
||||
v := strings.TrimSpace(r.PostFormValue(k))
|
||||
if err := a.Store.SetSetting(r.Context(), k, v); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) PostPasswordChange(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
|
||||
}
|
||||
current := r.PostFormValue("current_password")
|
||||
next := r.PostFormValue("new_password")
|
||||
confirm := r.PostFormValue("new_password_confirm")
|
||||
|
||||
d, err := a.settingsData(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case !auth.CheckPassword(cur.PasswordHash, current):
|
||||
d.PasswordError = "Current password is incorrect"
|
||||
case len(next) < auth.MinPasswordLen:
|
||||
d.PasswordError = fmt.Sprintf("New password must be at least %d characters", auth.MinPasswordLen)
|
||||
case next != confirm:
|
||||
d.PasswordError = "New passwords do not match"
|
||||
}
|
||||
if d.PasswordError != "" {
|
||||
render(w, r, templates.Settings(d))
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(next)
|
||||
if err != nil {
|
||||
http.Error(w, "hash error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := a.Store.UpdateUserPassword(r.Context(), cur.ID, hash); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
d.PasswordMsg = "Password updated"
|
||||
render(w, r, templates.Settings(d))
|
||||
}
|
||||
|
||||
func (a *App) PostTestNtfy(w http.ResponseWriter, r *http.Request) {
|
||||
cur := auth.CurrentUserFromRequest(r)
|
||||
if cur == nil || cur.Role != models.RoleAdmin {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
d, err := a.settingsData(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
baseURL := strings.TrimSpace(d.Values["ntfy_base_url"])
|
||||
topic := strings.TrimSpace(d.Values["ntfy_default_topic"])
|
||||
token := strings.TrimSpace(d.Values["ntfy_token"])
|
||||
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)
|
||||
if err := client.Send(r.Context(), ntfy.Notification{
|
||||
Topic: topic,
|
||||
Title: "Veola test",
|
||||
Message: "Test notification from Veola settings.",
|
||||
Priority: "default",
|
||||
Tags: []string{"white_check_mark"},
|
||||
}); err != nil {
|
||||
d.TestNtfyOK = "Ntfy test failed: " + err.Error()
|
||||
} else {
|
||||
d.TestNtfyOK = fmt.Sprintf("Sent test notification to %s/%s", baseURL, topic)
|
||||
}
|
||||
render(w, r, templates.Settings(d))
|
||||
}
|
||||
|
||||
func (a *App) PostTestApify(w http.ResponseWriter, r *http.Request) {
|
||||
cur := auth.CurrentUserFromRequest(r)
|
||||
if cur == nil || cur.Role != models.RoleAdmin {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
d, err := a.settingsData(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
apiKey := strings.TrimSpace(d.Values["apify_api_key"])
|
||||
actorID := a.Cfg.Apify.Actors.ActiveListings
|
||||
if apiKey == "" {
|
||||
apiKey = a.Cfg.Apify.APIKey
|
||||
}
|
||||
if apiKey == "" || actorID == "" {
|
||||
d.TestApifyOK = "Apify API key or active_listings actor is not configured."
|
||||
render(w, r, templates.Settings(d))
|
||||
return
|
||||
}
|
||||
client := apify.New(apiKey)
|
||||
var proxy *apify.ProxyConfiguration
|
||||
p := a.Cfg.Apify.Proxy
|
||||
if p.UseApifyProxy {
|
||||
proxy = &apify.ProxyConfiguration{
|
||||
UseApifyProxy: true,
|
||||
ApifyProxyGroups: p.Groups,
|
||||
ApifyProxyCountry: p.Country,
|
||||
}
|
||||
}
|
||||
raw, err := client.Run(r.Context(), actorID, apify.ActiveListingInput{
|
||||
SearchQueries: []string{"test"},
|
||||
MaxProductsPerSearch: 1,
|
||||
MaxSearchPages: 1,
|
||||
ListingType: "all",
|
||||
ProxyConfiguration: proxy,
|
||||
})
|
||||
if err != nil {
|
||||
d.TestApifyOK = "Apify test failed: " + err.Error()
|
||||
} else {
|
||||
d.TestApifyOK = fmt.Sprintf("Apify returned %d item(s).", len(raw))
|
||||
}
|
||||
render(w, r, templates.Settings(d))
|
||||
}
|
||||
101
internal/handlers/users.go
Normal file
101
internal/handlers/users.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"veola/internal/auth"
|
||||
"veola/internal/models"
|
||||
"veola/templates"
|
||||
)
|
||||
|
||||
func (a *App) renderSettingsWithUserMsg(w http.ResponseWriter, r *http.Request, msg, errMsg string) {
|
||||
d, err := a.settingsData(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
d.UserMsg = msg
|
||||
d.UserError = errMsg
|
||||
render(w, r, templates.Settings(d))
|
||||
}
|
||||
|
||||
func (a *App) PostCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
password := r.PostFormValue("password")
|
||||
role := strings.TrimSpace(r.PostFormValue("role"))
|
||||
if role != string(models.RoleAdmin) {
|
||||
role = string(models.RoleUser)
|
||||
}
|
||||
|
||||
switch {
|
||||
case username == "":
|
||||
a.renderSettingsWithUserMsg(w, r, "", "Username is required")
|
||||
return
|
||||
case len(password) < auth.MinPasswordLen:
|
||||
a.renderSettingsWithUserMsg(w, r, "", fmt.Sprintf("Password must be at least %d characters", auth.MinPasswordLen))
|
||||
return
|
||||
}
|
||||
existing, _ := a.Store.GetUserByUsername(r.Context(), username)
|
||||
if existing != nil {
|
||||
a.renderSettingsWithUserMsg(w, r, "", "User already exists")
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
a.renderSettingsWithUserMsg(w, r, "", "hash error")
|
||||
return
|
||||
}
|
||||
if _, err := a.Store.CreateUser(r.Context(), username, hash, models.Role(role)); err != nil {
|
||||
a.renderSettingsWithUserMsg(w, r, "", err.Error())
|
||||
return
|
||||
}
|
||||
a.renderSettingsWithUserMsg(w, r, "Created user "+username, "")
|
||||
}
|
||||
|
||||
func (a *App) PostDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
id := intParam(r, "id")
|
||||
cur := auth.CurrentUserFromRequest(r)
|
||||
if cur != nil && cur.ID == id {
|
||||
a.renderSettingsWithUserMsg(w, r, "", "You cannot delete your own account")
|
||||
return
|
||||
}
|
||||
if err := a.Store.DeleteUser(r.Context(), id); err != nil {
|
||||
a.renderSettingsWithUserMsg(w, r, "", err.Error())
|
||||
return
|
||||
}
|
||||
a.renderSettingsWithUserMsg(w, r, "User removed", "")
|
||||
}
|
||||
|
||||
func (a *App) PostResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
id := intParam(r, "id")
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
next := r.PostFormValue("new_password")
|
||||
if len(next) < auth.MinPasswordLen {
|
||||
a.renderSettingsWithUserMsg(w, r, "", fmt.Sprintf("Password must be at least %d characters", auth.MinPasswordLen))
|
||||
return
|
||||
}
|
||||
u, err := a.Store.GetUserByID(r.Context(), id)
|
||||
if err != nil || u == nil {
|
||||
a.renderSettingsWithUserMsg(w, r, "", "User not found")
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(next)
|
||||
if err != nil {
|
||||
a.renderSettingsWithUserMsg(w, r, "", "hash error")
|
||||
return
|
||||
}
|
||||
if err := a.Store.UpdateUserPassword(r.Context(), id, hash); err != nil {
|
||||
a.renderSettingsWithUserMsg(w, r, "", err.Error())
|
||||
return
|
||||
}
|
||||
a.renderSettingsWithUserMsg(w, r, "Password reset for "+u.Username, "")
|
||||
}
|
||||
122
internal/models/models.go
Normal file
122
internal/models/models.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleAdmin Role = "admin"
|
||||
RoleUser Role = "user"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int64
|
||||
Username string
|
||||
PasswordHash string
|
||||
Role Role
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Item struct {
|
||||
ID int64
|
||||
Name string
|
||||
SearchQuery string
|
||||
URL string
|
||||
Category string
|
||||
TargetPrice *float64
|
||||
NtfyTopic string
|
||||
NtfyPriority string
|
||||
PollIntervalMinutes int
|
||||
IncludeOutOfStock bool
|
||||
MinPrice *float64
|
||||
ExcludeKeywords string
|
||||
Marketplaces []string
|
||||
ListingType string
|
||||
ActorActive string
|
||||
ActorSold string
|
||||
ActorPriceCompare string
|
||||
UsePriceComparison bool
|
||||
Active bool
|
||||
LastPolledAt *time.Time
|
||||
LastPollError string
|
||||
BestPrice *float64
|
||||
BestPriceStore string
|
||||
BestPriceURL string
|
||||
BestPriceImageURL string
|
||||
BestPriceTitle string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
ID int64
|
||||
ItemID int64
|
||||
Title string
|
||||
Price *float64
|
||||
Currency string
|
||||
URL string
|
||||
Source string
|
||||
ImageURL string
|
||||
MatchedQuery string
|
||||
Alerted bool
|
||||
FoundAt time.Time
|
||||
}
|
||||
|
||||
// SearchQueries returns the item's alias list. Splits on newline, comma, and
|
||||
// semicolon; trims; drops blanks; dedupes case-insensitively. Result order is
|
||||
// the user's input order (first occurrence wins).
|
||||
func (it *Item) SearchQueries() []string {
|
||||
return SplitList(it.SearchQuery, 10)
|
||||
}
|
||||
|
||||
// ExcludeKeywordsList returns the item's exclude-keyword list, normalized the
|
||||
// same way as SearchQueries.
|
||||
func (it *Item) ExcludeKeywordsList() []string {
|
||||
return SplitList(it.ExcludeKeywords, 20)
|
||||
}
|
||||
|
||||
// SplitList splits a user-entered list on newline, comma, or semicolon,
|
||||
// trims whitespace, drops empty entries, dedupes case-insensitively, and caps
|
||||
// the result at max entries (0 = no cap).
|
||||
func SplitList(s string, max int) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, part := range strings.FieldsFunc(s, func(r rune) bool {
|
||||
return r == '\n' || r == '\r' || r == ',' || r == ';'
|
||||
}) {
|
||||
t := strings.TrimSpace(part)
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
k := strings.ToLower(t)
|
||||
if seen[k] {
|
||||
continue
|
||||
}
|
||||
seen[k] = true
|
||||
out = append(out, t)
|
||||
if max > 0 && len(out) >= max {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type PricePoint struct {
|
||||
ID int64
|
||||
ItemID int64
|
||||
Price float64
|
||||
Store string
|
||||
PolledAt time.Time
|
||||
}
|
||||
|
||||
type Setting struct {
|
||||
Key string
|
||||
Value string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
98
internal/ntfy/client.go
Normal file
98
internal/ntfy/client.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package ntfy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func New(baseURL string) *Client {
|
||||
return &Client{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
HTTP: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// NewWithToken returns a ntfy Client with bearer-token auth set. Use this
|
||||
// when the ntfy server requires authentication.
|
||||
func NewWithToken(baseURL, token string) *Client {
|
||||
c := New(baseURL)
|
||||
c.Token = strings.TrimSpace(token)
|
||||
return c
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
Topic string
|
||||
Title string
|
||||
Message string
|
||||
Priority string
|
||||
Tags []string
|
||||
Click string
|
||||
}
|
||||
|
||||
// Send publishes to ntfy using the topic-path + header style
|
||||
// (POST {base}/{topic} with metadata in HTTP headers and the message as the
|
||||
// raw body). This is the most broadly compatible ntfy publish method —
|
||||
// works on every ntfy version including self-hosted, and on any path layout
|
||||
// the server is mounted under.
|
||||
func (c *Client) Send(ctx context.Context, n Notification) error {
|
||||
if c.BaseURL == "" {
|
||||
return fmt.Errorf("ntfy base_url not configured")
|
||||
}
|
||||
if n.Topic == "" {
|
||||
return fmt.Errorf("ntfy topic required")
|
||||
}
|
||||
url := c.BaseURL + "/" + strings.TrimLeft(n.Topic, "/")
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(n.Message))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
if c.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
}
|
||||
if n.Title != "" {
|
||||
req.Header.Set("Title", n.Title)
|
||||
}
|
||||
if n.Priority != "" {
|
||||
req.Header.Set("Priority", n.Priority)
|
||||
}
|
||||
if len(n.Tags) > 0 {
|
||||
req.Header.Set("Tags", strings.Join(n.Tags, ","))
|
||||
}
|
||||
if n.Click != "" {
|
||||
req.Header.Set("Click", n.Click)
|
||||
}
|
||||
tokenLen := len(c.Token)
|
||||
tokenPrefix := ""
|
||||
if tokenLen >= 4 {
|
||||
tokenPrefix = c.Token[:4]
|
||||
}
|
||||
slog.Info("ntfy publish",
|
||||
"url", url,
|
||||
"topic", n.Topic,
|
||||
"auth_header_set", c.Token != "",
|
||||
"token_prefix", tokenPrefix,
|
||||
"token_len", tokenLen,
|
||||
)
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntfy POST: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return fmt.Errorf("ntfy returned %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
84
internal/scheduler/alert.go
Normal file
84
internal/scheduler/alert.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"veola/internal/apify"
|
||||
)
|
||||
|
||||
// ShouldAlert returns true if the result should trigger an alert.
|
||||
//
|
||||
// - Result must not already be in DB (caller checks that first).
|
||||
// - If targetPrice is nil, alert on every new result.
|
||||
// - If targetPrice is non-nil, alert only when price <= targetPrice.
|
||||
//
|
||||
// price=0 is treated as "unknown" and never alerts under a target.
|
||||
func ShouldAlert(targetPrice *float64, price float64) bool {
|
||||
if targetPrice == nil {
|
||||
return true
|
||||
}
|
||||
if price <= 0 {
|
||||
return false
|
||||
}
|
||||
return price <= *targetPrice
|
||||
}
|
||||
|
||||
// FilterResults applies match-confidence and out-of-stock filtering. Returns
|
||||
// a fresh slice; the input is not mutated.
|
||||
func FilterResults(in []apify.UnifiedResult, minConfidence float64, includeOOS bool) []apify.UnifiedResult {
|
||||
out := make([]apify.UnifiedResult, 0, len(in))
|
||||
for _, r := range in {
|
||||
if !includeOOS && r.OutOfStock {
|
||||
continue
|
||||
}
|
||||
if r.MatchConfidence != 0 && r.MatchConfidence < minConfidence {
|
||||
continue
|
||||
}
|
||||
if r.URL == "" || r.Price <= 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ApplyItemFilters drops results below minPrice (when set) and any whose title
|
||||
// contains one of excludeKeywords (case-insensitive substring match). Pass nil
|
||||
// or empty for either to skip that filter. Returns a fresh slice.
|
||||
func ApplyItemFilters(in []apify.UnifiedResult, minPrice *float64, excludeKeywords []string) []apify.UnifiedResult {
|
||||
lowered := make([]string, 0, len(excludeKeywords))
|
||||
for _, k := range excludeKeywords {
|
||||
k = strings.ToLower(strings.TrimSpace(k))
|
||||
if k != "" {
|
||||
lowered = append(lowered, k)
|
||||
}
|
||||
}
|
||||
out := make([]apify.UnifiedResult, 0, len(in))
|
||||
outer:
|
||||
for _, r := range in {
|
||||
if minPrice != nil && r.Price < *minPrice {
|
||||
continue
|
||||
}
|
||||
if len(lowered) > 0 {
|
||||
title := strings.ToLower(r.Title)
|
||||
for _, k := range lowered {
|
||||
if strings.Contains(title, k) {
|
||||
continue outer
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PickBest returns the index of the lowest-priced result, or -1 if none.
|
||||
func PickBest(rs []apify.UnifiedResult) int {
|
||||
best := -1
|
||||
for i, r := range rs {
|
||||
if best == -1 || r.Price < rs[best].Price {
|
||||
best = i
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
107
internal/scheduler/alert_test.go
Normal file
107
internal/scheduler/alert_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"veola/internal/apify"
|
||||
)
|
||||
|
||||
func ptr(f float64) *float64 { return &f }
|
||||
|
||||
func TestShouldAlert(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
target *float64
|
||||
price float64
|
||||
want bool
|
||||
}{
|
||||
{"no target alerts on any positive price", nil, 12.34, true},
|
||||
{"no target alerts even on zero price", nil, 0, true},
|
||||
{"price below target", ptr(60), 42, true},
|
||||
{"price equal to target", ptr(60), 60, true},
|
||||
{"price above target", ptr(60), 70, false},
|
||||
{"target set but price unknown", ptr(60), 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := ShouldAlert(c.target, c.price)
|
||||
if got != c.want {
|
||||
t.Errorf("got %v want %v", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterResults(t *testing.T) {
|
||||
in := []apify.UnifiedResult{
|
||||
{URL: "a", Price: 10, MatchConfidence: 0.9},
|
||||
{URL: "b", Price: 10, MatchConfidence: 0.4},
|
||||
{URL: "c", Price: 10, OutOfStock: true},
|
||||
{URL: "", Price: 10},
|
||||
{URL: "e", Price: 0},
|
||||
{URL: "f", Price: 12},
|
||||
}
|
||||
got := FilterResults(in, 0.6, false)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 results, got %d", len(got))
|
||||
}
|
||||
if got[0].URL != "a" || got[1].URL != "f" {
|
||||
t.Errorf("unexpected filter output: %+v", got)
|
||||
}
|
||||
got2 := FilterResults(in, 0.6, true)
|
||||
if len(got2) != 3 {
|
||||
t.Errorf("expected 3 with OOS, got %d", len(got2))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyItemFilters(t *testing.T) {
|
||||
in := []apify.UnifiedResult{
|
||||
{URL: "a", Title: "Sony A7 III body", Price: 1200},
|
||||
{URL: "b", Title: "Sony A7 III battery grip", Price: 45},
|
||||
{URL: "c", Title: "Sony A7 III lens cap", Price: 12},
|
||||
{URL: "d", Title: "Sony A7 III with strap", Price: 1100},
|
||||
{URL: "e", Title: "for parts not working", Price: 800},
|
||||
}
|
||||
got := ApplyItemFilters(in, ptr(100), []string{"grip", "lens cap", "for parts"})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 results after filter, got %d: %+v", len(got), got)
|
||||
}
|
||||
if got[0].URL != "a" || got[1].URL != "d" {
|
||||
t.Errorf("unexpected filter output: %+v", got)
|
||||
}
|
||||
|
||||
// Nil/empty filters are no-ops.
|
||||
got = ApplyItemFilters(in, nil, nil)
|
||||
if len(got) != len(in) {
|
||||
t.Errorf("nil filters dropped rows: got %d want %d", len(got), len(in))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupByURL(t *testing.T) {
|
||||
in := []apify.UnifiedResult{
|
||||
{Source: "ebay", URL: "https://a", MatchedQuery: "alpha"},
|
||||
{Source: "ebay", URL: "https://b", MatchedQuery: "alpha"},
|
||||
{Source: "ebay", URL: "https://a", MatchedQuery: "beta"}, // dup of #0
|
||||
{Source: "yahoo-auctions-jp", URL: "https://a"}, // different source, same url -> kept
|
||||
}
|
||||
got := DedupByURL(in)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("expected 3 deduped, got %d: %+v", len(got), got)
|
||||
}
|
||||
if got[0].MatchedQuery != "alpha" {
|
||||
t.Errorf("first-occurrence MatchedQuery lost: %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickBest(t *testing.T) {
|
||||
rs := []apify.UnifiedResult{
|
||||
{Price: 50}, {Price: 30}, {Price: 90}, {Price: 30},
|
||||
}
|
||||
got := PickBest(rs)
|
||||
if got != 1 {
|
||||
t.Errorf("expected index 1, got %d", got)
|
||||
}
|
||||
if PickBest(nil) != -1 {
|
||||
t.Error("expected -1 for empty")
|
||||
}
|
||||
}
|
||||
76
internal/scheduler/badge.go
Normal file
76
internal/scheduler/badge.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"veola/internal/models"
|
||||
"veola/templates"
|
||||
)
|
||||
|
||||
// PickBadge returns the highest-priority deal-quality badge that applies to
|
||||
// an item, or an empty BadgeData if none match. Order:
|
||||
// 1. All-time low
|
||||
// 2. X% below 30-day avg (only when at least 10% below)
|
||||
// 3. X% below target
|
||||
func PickBadge(it models.Item, history []models.PricePoint, now time.Time) templates.BadgeData {
|
||||
if it.BestPrice == nil {
|
||||
return templates.BadgeData{}
|
||||
}
|
||||
best := *it.BestPrice
|
||||
|
||||
// 1. All-time low
|
||||
if isAllTimeLow(best, history) {
|
||||
return templates.BadgeData{Label: "All-time low", Class: "v-badge-low"}
|
||||
}
|
||||
|
||||
// 2. X% below 30-day average
|
||||
if avg, ok := windowedMean(history, now, 30*24*time.Hour); ok && best > 0 && avg > 0 {
|
||||
pct := (avg - best) / avg * 100
|
||||
if pct >= 10 {
|
||||
return templates.BadgeData{
|
||||
Label: fmt.Sprintf("%d%% below 30-day avg", int(pct+0.5)),
|
||||
Class: "v-badge-avg",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. X% below target
|
||||
if it.TargetPrice != nil && *it.TargetPrice > 0 && best < *it.TargetPrice {
|
||||
pct := (*it.TargetPrice - best) / *it.TargetPrice * 100
|
||||
return templates.BadgeData{
|
||||
Label: fmt.Sprintf("%d%% below target", int(pct+0.5)),
|
||||
Class: "v-badge-target",
|
||||
}
|
||||
}
|
||||
|
||||
return templates.BadgeData{}
|
||||
}
|
||||
|
||||
func isAllTimeLow(best float64, history []models.PricePoint) bool {
|
||||
if len(history) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, p := range history {
|
||||
if p.Price > 0 && p.Price < best {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func windowedMean(history []models.PricePoint, now time.Time, window time.Duration) (float64, bool) {
|
||||
cutoff := now.Add(-window)
|
||||
sum, n := 0.0, 0
|
||||
for _, p := range history {
|
||||
if p.PolledAt.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
sum += p.Price
|
||||
n++
|
||||
}
|
||||
if n == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return sum / float64(n), true
|
||||
}
|
||||
95
internal/scheduler/badge_test.go
Normal file
95
internal/scheduler/badge_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"veola/internal/models"
|
||||
)
|
||||
|
||||
func bestItem(best, target float64) models.Item {
|
||||
bp := best
|
||||
it := models.Item{BestPrice: &bp}
|
||||
if target > 0 {
|
||||
t := target
|
||||
it.TargetPrice = &t
|
||||
}
|
||||
return it
|
||||
}
|
||||
|
||||
func TestPickBadgeAllTimeLow(t *testing.T) {
|
||||
now := time.Now()
|
||||
hist := []models.PricePoint{
|
||||
{Price: 100, PolledAt: now.Add(-40 * 24 * time.Hour)},
|
||||
{Price: 80, PolledAt: now.Add(-10 * 24 * time.Hour)},
|
||||
{Price: 60, PolledAt: now.Add(-1 * 24 * time.Hour)},
|
||||
}
|
||||
it := bestItem(50, 0)
|
||||
got := PickBadge(it, hist, now)
|
||||
if got.Label != "All-time low" {
|
||||
t.Errorf("expected all-time low, got %q", got.Label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickBadgeBelowAverage(t *testing.T) {
|
||||
now := time.Now()
|
||||
hist := []models.PricePoint{
|
||||
{Price: 100, PolledAt: now.Add(-25 * 24 * time.Hour)},
|
||||
{Price: 100, PolledAt: now.Add(-10 * 24 * time.Hour)},
|
||||
{Price: 100, PolledAt: now.Add(-5 * 24 * time.Hour)},
|
||||
}
|
||||
it := bestItem(80, 0) // 20% below 100 avg, not lowest because there's no lower in history but best is below points
|
||||
// add an older lower point so all-time-low is NOT triggered
|
||||
hist = append(hist, models.PricePoint{Price: 70, PolledAt: now.Add(-90 * 24 * time.Hour)})
|
||||
got := PickBadge(it, hist, now)
|
||||
if got.Label != "20% below 30-day avg" {
|
||||
t.Errorf("expected 20%% below 30-day avg, got %q", got.Label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickBadgeBelowTarget(t *testing.T) {
|
||||
now := time.Now()
|
||||
// 30-day window mean equals best (50) so avg badge does not fire.
|
||||
// An older lower point disables the all-time-low badge.
|
||||
hist := []models.PricePoint{
|
||||
{Price: 50, PolledAt: now.Add(-2 * 24 * time.Hour)},
|
||||
{Price: 50, PolledAt: now.Add(-1 * 24 * time.Hour)},
|
||||
{Price: 40, PolledAt: now.Add(-90 * 24 * time.Hour)},
|
||||
}
|
||||
it := bestItem(50, 100) // 50% below target
|
||||
got := PickBadge(it, hist, now)
|
||||
if got.Label != "50% below target" {
|
||||
t.Errorf("expected 50%% below target, got %q", got.Label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickBadgeNone(t *testing.T) {
|
||||
now := time.Now()
|
||||
// best matches recent avg, no target, and an older lower point exists -
|
||||
// no badge should fire.
|
||||
hist := []models.PricePoint{
|
||||
{Price: 50, PolledAt: now.Add(-1 * 24 * time.Hour)},
|
||||
{Price: 40, PolledAt: now.Add(-90 * 24 * time.Hour)},
|
||||
}
|
||||
it := bestItem(50, 0)
|
||||
got := PickBadge(it, hist, now)
|
||||
if got.Label != "" {
|
||||
t.Errorf("expected no badge, got %q", got.Label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickBadgeIgnoresShortAvgGap(t *testing.T) {
|
||||
now := time.Now()
|
||||
hist := []models.PricePoint{
|
||||
{Price: 100, PolledAt: now.Add(-1 * 24 * time.Hour)},
|
||||
{Price: 95, PolledAt: now.Add(-2 * 24 * time.Hour)},
|
||||
}
|
||||
// best 92 is only ~5.6% below avg 97.5 — under the 10% floor
|
||||
older := models.PricePoint{Price: 80, PolledAt: now.Add(-90 * 24 * time.Hour)}
|
||||
hist = append(hist, older)
|
||||
it := bestItem(92, 0)
|
||||
got := PickBadge(it, hist, now)
|
||||
if got.Label != "" {
|
||||
t.Errorf("expected no badge for <10%% gap, got %q", got.Label)
|
||||
}
|
||||
}
|
||||
7
internal/scheduler/json.go
Normal file
7
internal/scheduler/json.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package scheduler
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
func jsonUnmarshal(b []byte, dst any) error {
|
||||
return json.Unmarshal(b, dst)
|
||||
}
|
||||
599
internal/scheduler/scheduler.go
Normal file
599
internal/scheduler/scheduler.go
Normal file
@@ -0,0 +1,599 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
|
||||
"veola/internal/apify"
|
||||
"veola/internal/config"
|
||||
"veola/internal/db"
|
||||
"veola/internal/models"
|
||||
"veola/internal/ntfy"
|
||||
)
|
||||
|
||||
type Scheduler struct {
|
||||
cfg *config.Config
|
||||
store *db.Store
|
||||
apify *apify.Client
|
||||
ntfy *ntfy.Client
|
||||
cron *cron.Cron
|
||||
|
||||
mu sync.Mutex
|
||||
entries map[int64]cron.EntryID
|
||||
|
||||
rootCtx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, store *db.Store, ap *apify.Client, nt *ntfy.Client) *Scheduler {
|
||||
rootCtx, cancel := context.WithCancel(context.Background())
|
||||
return &Scheduler{
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
apify: ap,
|
||||
ntfy: nt,
|
||||
cron: cron.New(),
|
||||
entries: make(map[int64]cron.EntryID),
|
||||
rootCtx: rootCtx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) Start(ctx context.Context) error {
|
||||
items, err := s.store.ListActiveItems(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, it := range items {
|
||||
s.register(it)
|
||||
}
|
||||
s.cron.Start()
|
||||
slog.Info("scheduler started", "items", len(items))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop blocks until running jobs complete.
|
||||
func (s *Scheduler) Stop() {
|
||||
s.cancel()
|
||||
stopCtx := s.cron.Stop()
|
||||
<-stopCtx.Done()
|
||||
slog.Info("scheduler stopped")
|
||||
}
|
||||
|
||||
// SyncItem registers, re-registers, or removes the cron job for an item based
|
||||
// on its current Active flag. Call after create/update/toggle/delete.
|
||||
func (s *Scheduler) SyncItem(it models.Item) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if existing, ok := s.entries[it.ID]; ok {
|
||||
s.cron.Remove(existing)
|
||||
delete(s.entries, it.ID)
|
||||
}
|
||||
if !it.Active {
|
||||
return
|
||||
}
|
||||
s.registerLocked(it)
|
||||
}
|
||||
|
||||
func (s *Scheduler) RemoveItem(id int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if existing, ok := s.entries[id]; ok {
|
||||
s.cron.Remove(existing)
|
||||
delete(s.entries, id)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) register(it models.Item) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.registerLocked(it)
|
||||
}
|
||||
|
||||
func (s *Scheduler) registerLocked(it models.Item) {
|
||||
mins := it.PollIntervalMinutes
|
||||
if mins <= 0 {
|
||||
mins = s.cfg.Scheduler.GlobalPollIntervalMinutes
|
||||
}
|
||||
if mins <= 0 {
|
||||
mins = 60
|
||||
}
|
||||
spec := fmt.Sprintf("@every %dm", mins)
|
||||
id := it.ID
|
||||
entryID, err := s.cron.AddFunc(spec, func() {
|
||||
ctx, cancel := context.WithTimeout(s.rootCtx, 10*time.Minute)
|
||||
defer cancel()
|
||||
fresh, err := s.store.GetItem(ctx, id)
|
||||
if err != nil || fresh == nil || !fresh.Active {
|
||||
return
|
||||
}
|
||||
s.RunPoll(ctx, *fresh)
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("schedule failed", "item_id", it.ID, "err", err)
|
||||
return
|
||||
}
|
||||
s.entries[it.ID] = entryID
|
||||
}
|
||||
|
||||
// RunPoll executes one poll cycle for an item. Public so handlers can trigger
|
||||
// "Run Now" without going through cron. Iterates over each (alias × marketplace)
|
||||
// pair; a single failing combo does not poison the others.
|
||||
func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
|
||||
plans := s.buildAllInputs(it)
|
||||
if len(plans) == 0 {
|
||||
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
|
||||
for _, p := range plans {
|
||||
if p.actorID == "" {
|
||||
errs = append(errs, fmt.Sprintf("%s: no actor configured", p.marketplace))
|
||||
continue
|
||||
}
|
||||
raw, err := apifyClient.Run(ctx, p.actorID, p.input)
|
||||
if err != nil {
|
||||
label := p.marketplace
|
||||
if p.query != "" {
|
||||
label = fmt.Sprintf("query %q on %s", p.query, p.marketplace)
|
||||
}
|
||||
errs = append(errs, fmt.Sprintf("%s: %s", label, err.Error()))
|
||||
slog.Error("apify run failed", "item_id", it.ID, "marketplace", p.marketplace, "query", p.query, "err", err)
|
||||
continue
|
||||
}
|
||||
decoded, _ := apify.Decode(raw, p.source)
|
||||
usable := 0
|
||||
for i := range decoded {
|
||||
decoded[i].MatchedQuery = p.query
|
||||
if decoded[i].URL != "" && decoded[i].Price > 0 {
|
||||
usable++
|
||||
}
|
||||
}
|
||||
slog.Info("apify run decoded",
|
||||
"item_id", it.ID,
|
||||
"marketplace", p.marketplace,
|
||||
"query", p.query,
|
||||
"actor", p.actorID,
|
||||
"raw", len(raw),
|
||||
"decoded", len(decoded),
|
||||
"usable", usable,
|
||||
)
|
||||
if usable == 0 && len(raw) > 0 {
|
||||
var sample map[string]any
|
||||
if err := jsonUnmarshal(raw[0], &sample); err == nil {
|
||||
keys := make([]string, 0, len(sample))
|
||||
for k := range sample {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slog.Warn("decoded zero usable rows; raw item keys",
|
||||
"item_id", it.ID,
|
||||
"marketplace", p.marketplace,
|
||||
"actor", p.actorID,
|
||||
"keys", keys,
|
||||
)
|
||||
}
|
||||
}
|
||||
results = append(results, decoded...)
|
||||
successes++
|
||||
}
|
||||
if successes == 0 {
|
||||
s.recordError(ctx, it.ID, strings.Join(errs, "; "))
|
||||
return
|
||||
}
|
||||
|
||||
if it.UsePriceComparison {
|
||||
pcID := it.ActorPriceCompare
|
||||
if pcID == "" {
|
||||
pcID = s.cfg.Apify.Actors.PriceComparison
|
||||
}
|
||||
if pcID != "" {
|
||||
pcQueries := it.SearchQueries()
|
||||
if len(pcQueries) == 0 && it.URL != "" {
|
||||
pcQueries = []string{""}
|
||||
}
|
||||
for _, q := range pcQueries {
|
||||
pcRaw, err := apifyClient.Run(ctx, pcID, apify.PriceComparisonInput{
|
||||
Query: q, URL: it.URL,
|
||||
ProxyConfiguration: s.proxyConfig(),
|
||||
})
|
||||
if err == nil {
|
||||
pc, _ := apify.Decode(pcRaw, apify.SourcePriceCompare)
|
||||
for i := range pc {
|
||||
pc[i].MatchedQuery = q
|
||||
}
|
||||
results = append(results, pc...)
|
||||
} else {
|
||||
slog.Warn("price comparison failed", "item_id", it.ID, "query", q, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeDedup := len(results)
|
||||
results = DedupByURL(results)
|
||||
|
||||
threshold := s.cfg.Scheduler.MatchConfidenceThreshold
|
||||
beforeFilter := len(results)
|
||||
results = FilterResults(results, threshold, it.IncludeOutOfStock)
|
||||
results = ApplyItemFilters(results, it.MinPrice, it.ExcludeKeywordsList())
|
||||
slog.Info("filter applied",
|
||||
"item_id", it.ID,
|
||||
"before_dedup", beforeDedup,
|
||||
"before_filter", beforeFilter,
|
||||
"after", len(results),
|
||||
"min_confidence", threshold,
|
||||
"min_price", it.MinPrice,
|
||||
"exclude_count", len(it.ExcludeKeywordsList()),
|
||||
"include_out_of_stock", it.IncludeOutOfStock,
|
||||
)
|
||||
|
||||
bestIdx := PickBest(results)
|
||||
alertsSent := 0
|
||||
for _, r := range results {
|
||||
exists, err := s.store.ResultExists(ctx, it.ID, r.URL)
|
||||
if err != nil {
|
||||
slog.Error("dedup check failed", "err", err)
|
||||
continue
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
alerted := false
|
||||
if ShouldAlert(it.TargetPrice, r.Price) {
|
||||
if err := s.sendNotification(ctx, it, r); err != nil {
|
||||
slog.Error("ntfy send failed", "err", err)
|
||||
} else {
|
||||
alerted = true
|
||||
alertsSent++
|
||||
}
|
||||
}
|
||||
price := r.Price
|
||||
_, err = s.store.InsertResult(ctx, &models.Result{
|
||||
ItemID: it.ID,
|
||||
Title: r.Title,
|
||||
Price: &price,
|
||||
Currency: r.Currency,
|
||||
URL: r.URL,
|
||||
Source: r.Source,
|
||||
ImageURL: r.ImageURL,
|
||||
MatchedQuery: r.MatchedQuery,
|
||||
Alerted: alerted,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("insert result failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
errMsg := ""
|
||||
if len(errs) > 0 {
|
||||
errMsg = strings.Join(errs, "; ")
|
||||
}
|
||||
if bestIdx >= 0 {
|
||||
best := results[bestIdx]
|
||||
bp := best.Price
|
||||
_ = s.store.UpdateItemPollResult(ctx, it.ID, &models.Item{
|
||||
BestPrice: &bp,
|
||||
BestPriceStore: best.Store,
|
||||
BestPriceURL: best.URL,
|
||||
BestPriceImageURL: best.ImageURL,
|
||||
BestPriceTitle: best.Title,
|
||||
}, errMsg)
|
||||
_ = s.store.InsertPricePoint(ctx, &models.PricePoint{
|
||||
ItemID: it.ID,
|
||||
Price: bp,
|
||||
Store: best.Store,
|
||||
})
|
||||
} else {
|
||||
_ = s.store.UpdateItemPollResult(ctx, it.ID, nil, errMsg)
|
||||
}
|
||||
|
||||
slog.Info("poll completed",
|
||||
"item_id", it.ID,
|
||||
"item_name", it.Name,
|
||||
"marketplaces", len(plans),
|
||||
"successes", successes,
|
||||
"results", len(results),
|
||||
"alerts_sent", alertsSent,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Scheduler) recordError(ctx context.Context, id int64, msg string) {
|
||||
if err := s.store.UpdateItemPollResult(ctx, id, nil, msg); err != nil {
|
||||
slog.Error("record error failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// apifyClient returns an apify.Client whose API key reflects the latest
|
||||
// value from settings, falling back to config.toml.
|
||||
func (s *Scheduler) apifyClient(ctx context.Context) *apify.Client {
|
||||
key := s.cfg.Apify.APIKey
|
||||
if v, _ := s.store.GetSetting(ctx, "apify_api_key"); v != "" {
|
||||
key = v
|
||||
}
|
||||
return apify.New(key)
|
||||
}
|
||||
|
||||
func (s *Scheduler) sendNotification(ctx context.Context, it models.Item, r apify.UnifiedResult) error {
|
||||
tags := []string{"mag"}
|
||||
if it.TargetPrice != nil && r.Price <= *it.TargetPrice {
|
||||
tags = []string{"shopping_cart", "tada"}
|
||||
}
|
||||
priority := it.NtfyPriority
|
||||
if priority == "" {
|
||||
priority = "default"
|
||||
}
|
||||
topic := it.NtfyTopic
|
||||
if topic == "" {
|
||||
if v, _ := s.store.GetSetting(ctx, "ntfy_default_topic"); v != "" {
|
||||
topic = v
|
||||
} else {
|
||||
topic = s.cfg.Ntfy.DefaultTopic
|
||||
}
|
||||
}
|
||||
msg := fmt.Sprintf("%s %s%.2f", r.Store, currencyPrefix(r.Currency), r.Price)
|
||||
if it.TargetPrice != nil {
|
||||
msg += fmt.Sprintf(" (target: %s%.2f)", currencyPrefix(r.Currency), *it.TargetPrice)
|
||||
}
|
||||
if r.Title != "" {
|
||||
msg += "\n" + r.Title
|
||||
}
|
||||
baseURL := s.cfg.Ntfy.BaseURL
|
||||
if v, _ := s.store.GetSetting(ctx, "ntfy_base_url"); v != "" {
|
||||
baseURL = v
|
||||
}
|
||||
token, _ := s.store.GetSetting(ctx, "ntfy_token")
|
||||
client := ntfy.NewWithToken(baseURL, token)
|
||||
return client.Send(ctx, ntfy.Notification{
|
||||
Topic: topic,
|
||||
Title: fmt.Sprintf("Veola Alert: %s", it.Name),
|
||||
Message: msg,
|
||||
Priority: priority,
|
||||
Tags: tags,
|
||||
Click: r.URL,
|
||||
})
|
||||
}
|
||||
|
||||
func currencyPrefix(c string) string {
|
||||
switch c {
|
||||
case "USD", "":
|
||||
return "$"
|
||||
case "GBP":
|
||||
return "£"
|
||||
case "EUR":
|
||||
return "€"
|
||||
case "JPY":
|
||||
return "¥"
|
||||
}
|
||||
return c + " "
|
||||
}
|
||||
|
||||
// BuildPreviewInputs returns one actor plan per alias for the first marketplace
|
||||
// on the item. Preview deliberately uses only one marketplace to limit actor
|
||||
// runs, but exercises every alias so the operator sees the full result set.
|
||||
func (s *Scheduler) BuildPreviewInputs(it models.Item) []actorPlan {
|
||||
queries := it.SearchQueries()
|
||||
if len(queries) == 0 {
|
||||
queries = []string{""}
|
||||
}
|
||||
markets := it.Marketplaces
|
||||
if len(markets) > 1 {
|
||||
markets = markets[:1]
|
||||
}
|
||||
var out []actorPlan
|
||||
for _, q := range queries {
|
||||
out = append(out, s.buildInputsForQuery(it, q, markets)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type actorPlan struct {
|
||||
marketplace string
|
||||
source string
|
||||
actorID string
|
||||
query string
|
||||
input any
|
||||
}
|
||||
|
||||
// Marketplace returns the marketplace for this plan.
|
||||
func (p actorPlan) Marketplace() string { return p.marketplace }
|
||||
|
||||
// Source returns the result-source label (used to pick a decoder).
|
||||
func (p actorPlan) Source() string { return p.source }
|
||||
|
||||
// ActorID returns the Apify actor ID this plan will invoke.
|
||||
func (p actorPlan) ActorID() string { return p.actorID }
|
||||
|
||||
// Query returns the alias string this plan searches for. Empty for URL-only items.
|
||||
func (p actorPlan) Query() string { return p.query }
|
||||
|
||||
// Input returns the actor input payload as expected by apify.Client.Run.
|
||||
func (p actorPlan) Input() any { return p.input }
|
||||
|
||||
// buildAllInputs returns one actor plan per (alias × marketplace) for the item.
|
||||
// For URL-only items (no aliases), produces one plan per marketplace with an
|
||||
// empty query string.
|
||||
func (s *Scheduler) buildAllInputs(it models.Item) []actorPlan {
|
||||
queries := it.SearchQueries()
|
||||
if len(queries) == 0 {
|
||||
queries = []string{""}
|
||||
}
|
||||
markets := it.Marketplaces
|
||||
if len(markets) == 0 {
|
||||
markets = []string{"ebay.com"}
|
||||
}
|
||||
var out []actorPlan
|
||||
for _, q := range queries {
|
||||
out = append(out, s.buildInputsForQuery(it, q, markets)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildInputsForQuery returns one actor plan per marketplace, all using the
|
||||
// same query string. Used by both the scheduler and the preview path.
|
||||
func (s *Scheduler) buildInputsForQuery(it models.Item, query string, markets []string) []actorPlan {
|
||||
url := strings.ToLower(it.URL)
|
||||
plans := make([]actorPlan, 0, len(markets))
|
||||
for _, m := range markets {
|
||||
mk := strings.ToLower(m)
|
||||
switch {
|
||||
case strings.Contains(mk, "yahoo") || strings.Contains(url, "yahoo.co.jp"):
|
||||
actorID := firstNonEmpty(it.ActorActive, s.cfg.Apify.Actors.YahooAuctionsJP)
|
||||
plans = append(plans, actorPlan{m, apify.SourceYahooJP, actorID, query, apify.YahooAuctionsJPInput{
|
||||
SearchTerm: query,
|
||||
MaxPages: 1,
|
||||
}})
|
||||
case strings.Contains(mk, "mercari") || strings.Contains(url, "mercari"):
|
||||
actorID := firstNonEmpty(it.ActorActive, s.cfg.Apify.Actors.MercariJP)
|
||||
plans = append(plans, actorPlan{m, apify.SourceMercariJP, actorID, query, apify.MercariJPInput{
|
||||
SearchKeywords: []string{query},
|
||||
Status: "on_sale",
|
||||
MaxResults: 30,
|
||||
}})
|
||||
default:
|
||||
actorID := firstNonEmpty(it.ActorActive, s.cfg.Apify.Actors.ActiveListings)
|
||||
plans = append(plans, actorPlan{m, apify.SourceActiveEbay, actorID, query, apify.ActiveListingInput{
|
||||
SearchQueries: []string{query},
|
||||
MaxProductsPerSearch: 30,
|
||||
MaxSearchPages: 1,
|
||||
Sort: "best_match",
|
||||
ListingType: mapListingType(it.ListingType),
|
||||
ProxyConfiguration: s.proxyConfig(),
|
||||
}})
|
||||
}
|
||||
}
|
||||
return plans
|
||||
}
|
||||
|
||||
// DedupByURL collapses duplicates within a single result set. When the same
|
||||
// listing matches multiple aliases the first occurrence wins, including its
|
||||
// MatchedQuery tag.
|
||||
func DedupByURL(in []apify.UnifiedResult) []apify.UnifiedResult {
|
||||
seen := map[string]bool{}
|
||||
out := make([]apify.UnifiedResult, 0, len(in))
|
||||
for _, r := range in {
|
||||
if r.URL == "" {
|
||||
out = append(out, r)
|
||||
continue
|
||||
}
|
||||
key := r.Source + "|" + r.URL
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// proxyConfig returns the apify proxyConfiguration block built from
|
||||
// config.toml. Returns nil — meaning omit the field from actor input
|
||||
// entirely — if use_apify_proxy is false. Group and country are ignored when
|
||||
// use_apify_proxy is false to prevent contradictory input.
|
||||
func (s *Scheduler) proxyConfig() *apify.ProxyConfiguration {
|
||||
p := s.cfg.Apify.Proxy
|
||||
if !p.UseApifyProxy {
|
||||
return nil
|
||||
}
|
||||
return &apify.ProxyConfiguration{
|
||||
UseApifyProxy: true,
|
||||
ApifyProxyGroups: p.Groups,
|
||||
ApifyProxyCountry: p.Country,
|
||||
}
|
||||
}
|
||||
|
||||
// mapListingType translates Veola's listing-type vocabulary ("all", "BIN",
|
||||
// "auction") into the automation-lab/ebay-scraper input vocabulary
|
||||
// ("all", "buy_it_now", "auction"). Unrecognized values fall through as-is
|
||||
// in case the user pasted a value the actor accepts but we don't.
|
||||
func mapListingType(s string) string {
|
||||
switch strings.ToLower(s) {
|
||||
case "", "all":
|
||||
return "all"
|
||||
case "bin", "buy_it_now":
|
||||
return "buy_it_now"
|
||||
case "auction":
|
||||
return "auction"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func firstNonEmpty(vs ...string) string {
|
||||
for _, v := range vs {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SeedSoldHistory runs the sold-listings actor and writes price_history rows
|
||||
// for an item just added. Errors are logged and swallowed: a missing baseline
|
||||
// is not fatal.
|
||||
func (s *Scheduler) SeedSoldHistory(ctx context.Context, it models.Item) {
|
||||
queries := it.SearchQueries()
|
||||
if len(queries) == 0 {
|
||||
return
|
||||
}
|
||||
markets := it.Marketplaces
|
||||
if len(markets) == 0 {
|
||||
markets = []string{"ebay.com"}
|
||||
}
|
||||
for _, q := range queries {
|
||||
for _, m := range markets {
|
||||
s.seedSoldHistoryFor(ctx, it, q, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) seedSoldHistoryFor(ctx context.Context, it models.Item, query, marketplace string) {
|
||||
actorID := firstNonEmpty(it.ActorSold, s.cfg.Apify.Actors.SoldListings)
|
||||
source := apify.SourceSoldEbay
|
||||
if strings.Contains(strings.ToLower(marketplace), "yahoo") {
|
||||
actorID = firstNonEmpty(it.ActorSold, s.cfg.Apify.Actors.YahooAuctionsJPSold)
|
||||
source = apify.SourceSoldYahooJP
|
||||
}
|
||||
if actorID == "" {
|
||||
return
|
||||
}
|
||||
raw, err := s.apifyClient(ctx).Run(ctx, actorID, apify.SoldListingInput{
|
||||
Query: query, Marketplace: marketplace, MaxResults: 50, DaysBack: 30,
|
||||
ProxyConfiguration: s.proxyConfig(),
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("sold history seed failed", "item_id", it.ID, "marketplace", marketplace, "query", query, "err", err)
|
||||
return
|
||||
}
|
||||
for _, r := range raw {
|
||||
var sold apify.SoldListingResult
|
||||
if err := jsonUnmarshal(r, &sold); err != nil || sold.SoldPrice <= 0 {
|
||||
continue
|
||||
}
|
||||
t, _ := time.Parse(time.RFC3339, sold.SoldAt)
|
||||
if t.IsZero() {
|
||||
t = time.Now()
|
||||
}
|
||||
_ = s.store.InsertPricePoint(ctx, &models.PricePoint{
|
||||
ItemID: it.ID,
|
||||
Price: sold.SoldPrice,
|
||||
Store: sourceLabelToStore(source),
|
||||
PolledAt: t,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func sourceLabelToStore(src string) string {
|
||||
switch src {
|
||||
case apify.SourceSoldYahooJP:
|
||||
return "yahoo-auctions-jp-sold"
|
||||
}
|
||||
return "ebay-sold"
|
||||
}
|
||||
105
main.go
Normal file
105
main.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"veola/internal/apify"
|
||||
"veola/internal/auth"
|
||||
"veola/internal/config"
|
||||
"veola/internal/crypto"
|
||||
"veola/internal/db"
|
||||
"veola/internal/handlers"
|
||||
"veola/internal/ntfy"
|
||||
"veola/internal/scheduler"
|
||||
)
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", "config.toml", "path to config TOML file")
|
||||
flag.Parse()
|
||||
|
||||
if err := run(*configPath); err != nil {
|
||||
slog.Error("fatal", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(configPath string) error {
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: %w", err)
|
||||
}
|
||||
|
||||
key, err := crypto.DeriveKey([]byte(cfg.Security.EncryptionKey))
|
||||
if err != nil {
|
||||
return fmt.Errorf("derive key: %w", err)
|
||||
}
|
||||
|
||||
sqlDB, err := db.Open(cfg.Server.DBPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db open: %w", err)
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
store := db.NewStore(sqlDB, key)
|
||||
authMgr, err := auth.NewManager(sqlDB, store, cfg.Security.SessionSecret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth manager: %w", err)
|
||||
}
|
||||
|
||||
apifyClient := apify.New(cfg.Apify.APIKey)
|
||||
ntfyClient := ntfy.New(cfg.Ntfy.BaseURL)
|
||||
sched := scheduler.New(cfg, store, apifyClient, ntfyClient)
|
||||
|
||||
startCtx, cancelStart := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancelStart()
|
||||
if err := sched.Start(startCtx); err != nil {
|
||||
return fmt.Errorf("scheduler start: %w", err)
|
||||
}
|
||||
|
||||
app := handlers.New(cfg, store, authMgr, apifyClient, ntfyClient, sched)
|
||||
addr := fmt.Sprintf(":%d", cfg.Server.Port)
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: app.Routes(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
slog.Info("listening", "addr", addr)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- err
|
||||
}
|
||||
close(errCh)
|
||||
}()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
select {
|
||||
case sig := <-sigCh:
|
||||
slog.Info("shutting down", "signal", sig.String())
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
return fmt.Errorf("http: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancelShutdown()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
slog.Error("http shutdown", "err", err)
|
||||
}
|
||||
sched.Stop()
|
||||
slog.Info("shutdown complete")
|
||||
return nil
|
||||
}
|
||||
209
static/css/app.css
Normal file
209
static/css/app.css
Normal file
@@ -0,0 +1,209 @@
|
||||
/* Veola — Sega-blue palette and component overrides for Tailwind play CDN. */
|
||||
|
||||
:root {
|
||||
--bg: #1a2b6d;
|
||||
--surface: #1f3380;
|
||||
--surface-2: #243a93;
|
||||
--accent: #00a4e4;
|
||||
--yellow: #f5c400;
|
||||
--text: #ffffff;
|
||||
--text-2: #a8c0f0;
|
||||
--danger: #e84040;
|
||||
--success: #00e4a4;
|
||||
--border: rgba(255, 255, 255, 0.12);
|
||||
--shadow: 0 4px 16px rgba(0, 0, 80, 0.4);
|
||||
}
|
||||
|
||||
html, body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Outfit', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.font-mono { font-family: 'JetBrains Mono', ui-monospace, monospace; }
|
||||
|
||||
a { color: var(--accent); }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
.v-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.v-card-flat {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.v-divider { border-top: 1px solid var(--border); }
|
||||
|
||||
.v-btn {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 1rem;
|
||||
font-weight: 600;
|
||||
transition: filter 0.1s ease;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.v-btn:hover { filter: brightness(1.1); }
|
||||
.v-btn[disabled] { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.v-btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.v-btn-ghost:hover { background: rgba(255,255,255,0.05); }
|
||||
|
||||
.v-btn-danger {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.v-input, .v-select, .v-textarea {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
}
|
||||
.v-input:focus, .v-select:focus, .v-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(0, 164, 228, 0.25);
|
||||
}
|
||||
|
||||
.v-label {
|
||||
display: block;
|
||||
color: var(--text-2);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.3rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.v-pill {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.v-pill-active { background: var(--accent); color: white; }
|
||||
.v-pill-paused { background: rgba(255,255,255,0.1); color: var(--text-2); }
|
||||
.v-pill-error { background: var(--danger); color: white; }
|
||||
|
||||
.v-price { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 1.5rem; }
|
||||
.v-price-target { color: var(--yellow); }
|
||||
.v-price-deal { color: var(--success); }
|
||||
|
||||
.v-side-nav {
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
width: 220px;
|
||||
min-height: 100vh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.v-side-nav a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.7rem 1rem;
|
||||
color: var(--text-2);
|
||||
text-decoration: none;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
.v-side-nav a.active {
|
||||
background: var(--surface-2);
|
||||
border-left-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
.v-side-nav a:hover { color: white; }
|
||||
|
||||
.v-veola-portrait {
|
||||
background: #f3ead8;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.v-veola-portrait img { display: block; max-width: 100%; height: auto; }
|
||||
|
||||
.v-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.v-badge-low { background: var(--success); color: #053b2c; }
|
||||
.v-badge-avg { background: var(--accent); color: white; }
|
||||
.v-badge-target { background: var(--yellow); color: #2a2200; }
|
||||
|
||||
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;
|
||||
color: var(--text-2);
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
table.v-table td {
|
||||
padding: 0.7rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
table.v-table tr:hover td { background: rgba(255,255,255,0.03); }
|
||||
|
||||
.v-error-text { color: var(--danger); font-size: 0.85rem; }
|
||||
.v-muted { color: var(--text-2); }
|
||||
|
||||
.v-flash {
|
||||
background: rgba(0, 164, 228, 0.15);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.v-flash-error {
|
||||
background: rgba(232, 64, 64, 0.15);
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* htmx indicator: hidden by default, visible during in-flight requests. */
|
||||
.htmx-indicator { display: none; }
|
||||
.htmx-request .htmx-indicator,
|
||||
.htmx-request.htmx-indicator { display: inline-flex; }
|
||||
|
||||
.v-spinner {
|
||||
display: inline-block;
|
||||
width: 14px; height: 14px;
|
||||
border: 2px solid rgba(255,255,255,0.2);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: v-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes v-spin { to { transform: rotate(360deg); } }
|
||||
BIN
static/img/veola.webp
Normal file
BIN
static/img/veola.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
20
static/vendor/chart.umd.min.js
vendored
Normal file
20
static/vendor/chart.umd.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
static/vendor/htmx.min.js
vendored
Normal file
1
static/vendor/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
165
templates/dashboard.templ
Normal file
165
templates/dashboard.templ
Normal file
@@ -0,0 +1,165 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"veola/internal/db"
|
||||
"veola/internal/models"
|
||||
)
|
||||
|
||||
type DashboardData struct {
|
||||
Page
|
||||
Stats *db.DashboardStats
|
||||
RecentResults []ResultRow
|
||||
RecentAlerts []AlertRow
|
||||
}
|
||||
|
||||
type ResultRow struct {
|
||||
ItemID int64
|
||||
ItemName string
|
||||
Title string
|
||||
Price *float64
|
||||
Currency string
|
||||
Source string
|
||||
URL string
|
||||
FoundAt time.Time
|
||||
Alerted bool
|
||||
}
|
||||
|
||||
type AlertRow struct {
|
||||
ItemName string
|
||||
Price *float64
|
||||
Currency string
|
||||
FoundAt time.Time
|
||||
}
|
||||
|
||||
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), "")
|
||||
</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">{ 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>
|
||||
}
|
||||
</div>
|
||||
<div class="v-card p-5">
|
||||
<div class="v-muted text-sm uppercase tracking-wide">Money Saved</div>
|
||||
<div class="font-mono text-4xl mt-2 v-price-deal">{ fmt.Sprintf("$%.2f", d.Stats.MoneySaved) }</div>
|
||||
<div class="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 len(d.RecentResults) == 0 {
|
||||
<div class="v-muted text-sm">No results yet.</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>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</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">{ value }</div>
|
||||
if sub != "" {
|
||||
<div class="v-muted text-xs mt-1">{ sub }</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ Dashboard(d DashboardData) {
|
||||
@Layout(d.Page, dashboardBody(d))
|
||||
}
|
||||
|
||||
// Helpers used by multiple templates.
|
||||
|
||||
func fmtPrice(p *float64, currency string) string {
|
||||
if p == nil {
|
||||
return "—"
|
||||
}
|
||||
sym := currencySymbol(currency)
|
||||
return fmt.Sprintf("%s%.2f", sym, *p)
|
||||
}
|
||||
|
||||
func currencySymbol(c string) string {
|
||||
switch c {
|
||||
case "USD", "":
|
||||
return "$"
|
||||
case "GBP":
|
||||
return "£"
|
||||
case "EUR":
|
||||
return "€"
|
||||
case "JPY":
|
||||
return "¥"
|
||||
default:
|
||||
return c + " "
|
||||
}
|
||||
}
|
||||
|
||||
func humanTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "—"
|
||||
}
|
||||
d := time.Since(t)
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "just now"
|
||||
case d < time.Hour:
|
||||
m := int(d.Minutes())
|
||||
return fmt.Sprintf("%d minutes ago", m)
|
||||
case d < 24*time.Hour:
|
||||
h := int(d.Hours())
|
||||
return fmt.Sprintf("%d hours ago", h)
|
||||
case d < 30*24*time.Hour:
|
||||
d2 := int(d.Hours() / 24)
|
||||
return fmt.Sprintf("%d days ago", d2)
|
||||
default:
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
|
||||
// Used by item rendering.
|
||||
var _ = models.Item{}
|
||||
467
templates/dashboard_templ.go
Normal file
467
templates/dashboard_templ.go
Normal file
@@ -0,0 +1,467 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"veola/internal/db"
|
||||
"veola/internal/models"
|
||||
)
|
||||
|
||||
type DashboardData struct {
|
||||
Page
|
||||
Stats *db.DashboardStats
|
||||
RecentResults []ResultRow
|
||||
RecentAlerts []AlertRow
|
||||
}
|
||||
|
||||
type ResultRow struct {
|
||||
ItemID int64
|
||||
ItemName string
|
||||
Title string
|
||||
Price *float64
|
||||
Currency string
|
||||
Source string
|
||||
URL string
|
||||
FoundAt time.Time
|
||||
Alerted bool
|
||||
}
|
||||
|
||||
type AlertRow struct {
|
||||
ItemName string
|
||||
Price *float64
|
||||
Currency string
|
||||
FoundAt time.Time
|
||||
}
|
||||
|
||||
func dashboardBody(d DashboardData) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
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_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
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\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.2f", d.Stats.PotentialSpend))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 49, Col: 87}
|
||||
}
|
||||
_, 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 ")
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 50, Col: 89}
|
||||
}
|
||||
_, 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>")
|
||||
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\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d items not yet priced.", d.Stats.UnpricedCount))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 52, Col: 103}
|
||||
}
|
||||
_, 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>")
|
||||
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\">")
|
||||
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: 57, Col: 96}
|
||||
}
|
||||
_, 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 ")
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 58, Col: 88}
|
||||
}
|
||||
_, 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>")
|
||||
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>")
|
||||
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>")
|
||||
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=\"")
|
||||
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)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 74, Col: 80}
|
||||
}
|
||||
_, 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, 13, "\">")
|
||||
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: 74, Col: 95}
|
||||
}
|
||||
_, 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, 14, "</a></td><td class=\"font-mono\">")
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 75, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</td><td>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(r.Source)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 76, Col: 23}
|
||||
}
|
||||
_, 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, 16, "</td><td class=\"v-muted text-sm\">")
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 77, Col: 59}
|
||||
}
|
||||
_, 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, 17, "</td></tr>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</tbody></table>")
|
||||
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>")
|
||||
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>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<ul class=\"space-y-2\">")
|
||||
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>")
|
||||
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: 92, Col: 26}
|
||||
}
|
||||
_, 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, 23, "</span> <span class=\"font-mono v-price-target\">")
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 93, Col: 78}
|
||||
}
|
||||
_, 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, "</span></li>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</ul>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func statCard(label, value, sub 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_Var14 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var14 == nil {
|
||||
templ_7745c5c3_Var14 = 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: 105, 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\">")
|
||||
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: 106, Col: 46}
|
||||
}
|
||||
_, 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 templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(sub)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 108, Col: 42}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func Dashboard(d DashboardData) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
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_Var18 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var18 == nil {
|
||||
templ_7745c5c3_Var18 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = Layout(d.Page, dashboardBody(d)).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Helpers used by multiple templates.
|
||||
|
||||
func fmtPrice(p *float64, currency string) string {
|
||||
if p == nil {
|
||||
return "—"
|
||||
}
|
||||
sym := currencySymbol(currency)
|
||||
return fmt.Sprintf("%s%.2f", sym, *p)
|
||||
}
|
||||
|
||||
func currencySymbol(c string) string {
|
||||
switch c {
|
||||
case "USD", "":
|
||||
return "$"
|
||||
case "GBP":
|
||||
return "£"
|
||||
case "EUR":
|
||||
return "€"
|
||||
case "JPY":
|
||||
return "¥"
|
||||
default:
|
||||
return c + " "
|
||||
}
|
||||
}
|
||||
|
||||
func humanTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "—"
|
||||
}
|
||||
d := time.Since(t)
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "just now"
|
||||
case d < time.Hour:
|
||||
m := int(d.Minutes())
|
||||
return fmt.Sprintf("%d minutes ago", m)
|
||||
case d < 24*time.Hour:
|
||||
h := int(d.Hours())
|
||||
return fmt.Sprintf("%d hours ago", h)
|
||||
case d < 30*24*time.Hour:
|
||||
d2 := int(d.Hours() / 24)
|
||||
return fmt.Sprintf("%d days ago", d2)
|
||||
default:
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
|
||||
// Used by item rendering.
|
||||
var _ = models.Item{}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
302
templates/item_form.templ
Normal file
302
templates/item_form.templ
Normal file
@@ -0,0 +1,302 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"veola/internal/models"
|
||||
)
|
||||
|
||||
type ItemFormData struct {
|
||||
Page
|
||||
IsEdit bool
|
||||
Item models.Item
|
||||
Categories []string
|
||||
Errors []string
|
||||
}
|
||||
|
||||
func itemSelected(have, want string) bool { return have == want }
|
||||
|
||||
type marketplaceOpt struct {
|
||||
Value string
|
||||
Label string
|
||||
}
|
||||
|
||||
func marketplaceOptions() []marketplaceOpt {
|
||||
return []marketplaceOpt{
|
||||
{"ebay.com", "eBay (US)"},
|
||||
{"ebay.co.uk", "eBay (UK)"},
|
||||
{"ebay.de", "eBay (DE)"},
|
||||
{"ebay.fr", "eBay (FR)"},
|
||||
{"ebay.com.au", "eBay (AU)"},
|
||||
{"ebay.ca", "eBay (CA)"},
|
||||
{"yahoo.co.jp", "Yahoo Auctions JP"},
|
||||
{"mercari.jp", "Mercari JP"},
|
||||
}
|
||||
}
|
||||
|
||||
func isKnownMarketplace(v string) bool {
|
||||
for _, o := range marketplaceOptions() {
|
||||
if o.Value == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func itemHasMarketplace(it models.Item, v string) bool {
|
||||
for _, m := range it.Marketplaces {
|
||||
if m == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// customMarketplacesCSV returns the comma-separated list of marketplaces on
|
||||
// the item that are NOT in the curated list, so the user can keep editing
|
||||
// unusual values without losing them.
|
||||
func customMarketplacesCSV(it models.Item) string {
|
||||
var custom []string
|
||||
for _, m := range it.Marketplaces {
|
||||
if !isKnownMarketplace(m) {
|
||||
custom = append(custom, m)
|
||||
}
|
||||
}
|
||||
return joinCSV(custom)
|
||||
}
|
||||
|
||||
func joinCSV(vs []string) string {
|
||||
out := ""
|
||||
for i, v := range vs {
|
||||
if i > 0 {
|
||||
out += ", "
|
||||
}
|
||||
out += v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func itemHasJapanMarketplace(it models.Item) bool {
|
||||
for _, m := range it.Marketplaces {
|
||||
if m == "yahoo.co.jp" || m == "mercari.jp" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func newCategory(v string, known []string) string {
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
for _, c := range known {
|
||||
if c == v {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
templ itemFormBody(d ItemFormData) {
|
||||
<div>
|
||||
<h1 class="text-3xl font-semibold mb-6">
|
||||
if d.IsEdit {
|
||||
Edit { d.Item.Name }
|
||||
} else {
|
||||
Add Item
|
||||
}
|
||||
</h1>
|
||||
if len(d.Errors) > 0 {
|
||||
<div class="v-flash-error">
|
||||
<ul class="list-disc pl-5">
|
||||
for _, e := range d.Errors {
|
||||
<li>{ e }</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
@itemFormInner(d)
|
||||
</div>
|
||||
}
|
||||
|
||||
templ itemFormInner(d ItemFormData) {
|
||||
<form
|
||||
method="post"
|
||||
action={ formAction(d) }
|
||||
if !d.IsEdit {
|
||||
hx-post="/items/preview"
|
||||
hx-target="#preview-target"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#preview-loading"
|
||||
hx-disabled-elt="find button[type='submit']"
|
||||
}
|
||||
class="v-card p-6 space-y-4 max-w-3xl"
|
||||
>
|
||||
@CSRFInput(d.CSRFToken)
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="v-label">Name</label>
|
||||
<input class="v-input" name="name" value={ d.Item.Name } required/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="v-label">Category</label>
|
||||
<select class="v-select" name="category">
|
||||
<option value="" selected?={ d.Item.Category == "" }>— none —</option>
|
||||
for _, c := range d.Categories {
|
||||
<option value={ c } selected?={ d.Item.Category == c }>{ c }</option>
|
||||
}
|
||||
</select>
|
||||
<input class="v-input mt-2 text-sm" name="category_new" value={ newCategory(d.Item.Category, d.Categories) } placeholder="New category (overrides above)"/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="v-label">Search Queries</label>
|
||||
<textarea class="v-textarea" name="search_query" rows="4" lang="ja" placeholder="One per line, or comma / semicolon separated">{ d.Item.SearchQuery }</textarea>
|
||||
<div class="v-muted text-xs mt-1">Each is searched independently; the lowest price across all wins. Cost scales with the number of queries.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="v-label">Product URL</label>
|
||||
<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>
|
||||
<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={ optFloat(d.Item.TargetPrice) }/>
|
||||
<div class="v-muted text-xs mt-1">Blank = alert on every new result.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="v-label">Ntfy Topic</label>
|
||||
<input class="v-input" name="ntfy_topic" value={ d.Item.NtfyTopic } required/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="v-label">Ntfy Priority</label>
|
||||
<select class="v-select" name="ntfy_priority">
|
||||
for _, p := range []string{"min","low","default","high","urgent"} {
|
||||
<option value={ p } selected?={ itemSelected(d.Item.NtfyPriority, p) }>{ p }</option>
|
||||
}
|
||||
</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">
|
||||
for _, opt := range pollOptions() {
|
||||
<option value={ fmt.Sprintf("%d", opt.Minutes) } selected?={ d.Item.PollIntervalMinutes == opt.Minutes }>{ opt.Label }</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="v-label">Marketplaces</label>
|
||||
<div id="marketplace-grid" class="grid grid-cols-2 gap-1" onchange="document.getElementById('marketplace-jp-note').hidden = !document.querySelector('#marketplace-grid input[value="yahoo.co.jp"]:checked, #marketplace-grid input[value="mercari.jp"]:checked')">
|
||||
for _, m := range marketplaceOptions() {
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" name="marketplace" value={ m.Value } checked?={ itemHasMarketplace(d.Item, m.Value) }/>
|
||||
<span>{ m.Label }</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
<input class="v-input mt-2 text-sm" name="marketplace_custom" value={ customMarketplacesCSV(d.Item) } placeholder="Custom (comma-separated, added to above)"/>
|
||||
<div
|
||||
id="marketplace-jp-note"
|
||||
class="v-flash mt-2 text-sm"
|
||||
hidden?={ !itemHasJapanMarketplace(d.Item) }
|
||||
>Yahoo Auctions JP and Mercari JP search in Japanese. English queries return few or no results.</div>
|
||||
<div class="v-muted text-xs mt-1">Pick one or more. Each is polled per cycle; one failure does not stop the others.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="v-label">Listing Type</label>
|
||||
<select class="v-select" name="listing_type">
|
||||
for _, lt := range []string{"all","BIN","auction"} {
|
||||
<option value={ lt } selected?={ itemSelected(d.Item.ListingType, lt) }>{ lt }</option>
|
||||
}
|
||||
</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={ optFloat(d.Item.MinPrice) }/>
|
||||
<div class="v-muted text-xs mt-1">Drop results below this price. Filters out accessories and obvious junk.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="v-label">Exclude Keywords</label>
|
||||
<textarea class="v-textarea" name="exclude_keywords" rows="3" placeholder="One per line, or comma separated">{ d.Item.ExcludeKeywords }</textarea>
|
||||
<div class="v-muted text-xs mt-1">Drop results whose title contains any of these. Case-insensitive substring match.</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" name="include_out_of_stock" checked?={ d.Item.IncludeOutOfStock } 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={ d.Item.ActorActive } placeholder="from config"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="v-label">Sold Listings Actor</label>
|
||||
<input class="v-input" name="actor_sold" value={ d.Item.ActorSold } placeholder="from config"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="v-label">Price Comparison Actor</label>
|
||||
<input class="v-input" name="actor_price_compare" value={ d.Item.ActorPriceCompare } placeholder="from config"/>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 mt-6">
|
||||
<input type="checkbox" name="use_price_comparison" checked?={ d.Item.UsePriceComparison } value="1"/>
|
||||
<span>Use price comparison actor in addition to active listings</span>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
<div class="flex items-center gap-3 pt-2">
|
||||
if d.IsEdit {
|
||||
<button class="v-btn" type="submit">Save</button>
|
||||
<a class="v-btn-ghost" href="/items">Cancel</a>
|
||||
} else {
|
||||
<button class="v-btn" type="submit">Preview</button>
|
||||
<a class="v-btn-ghost" href="/items">Cancel</a>
|
||||
<span id="preview-loading" class="htmx-indicator v-muted text-sm flex items-center gap-2">
|
||||
<span class="v-spinner"></span>
|
||||
Running preview… apify runs can take 30–60s.
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
if !d.IsEdit {
|
||||
<div id="preview-target" class="mt-6"></div>
|
||||
}
|
||||
}
|
||||
|
||||
func formAction(d ItemFormData) templ.SafeURL {
|
||||
if d.IsEdit {
|
||||
return templ.SafeURL(fmt.Sprintf("/items/%d", d.Item.ID))
|
||||
}
|
||||
return templ.SafeURL("/items/preview")
|
||||
}
|
||||
|
||||
type pollOpt struct {
|
||||
Minutes int
|
||||
Label string
|
||||
}
|
||||
|
||||
func pollOptions() []pollOpt {
|
||||
return []pollOpt{
|
||||
{15, "15 min"}, {30, "30 min"}, {60, "1 hr"}, {120, "2 hr"},
|
||||
{360, "6 hr"}, {720, "12 hr"}, {1440, "24 hr"},
|
||||
}
|
||||
}
|
||||
|
||||
func optFloat(p *float64) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%.2f", *p)
|
||||
}
|
||||
|
||||
templ ItemForm(d ItemFormData) {
|
||||
@Layout(d.Page, itemFormBody(d))
|
||||
}
|
||||
760
templates/item_form_templ.go
Normal file
760
templates/item_form_templ.go
Normal file
@@ -0,0 +1,760 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"veola/internal/models"
|
||||
)
|
||||
|
||||
type ItemFormData struct {
|
||||
Page
|
||||
IsEdit bool
|
||||
Item models.Item
|
||||
Categories []string
|
||||
Errors []string
|
||||
}
|
||||
|
||||
func itemSelected(have, want string) bool { return have == want }
|
||||
|
||||
type marketplaceOpt struct {
|
||||
Value string
|
||||
Label string
|
||||
}
|
||||
|
||||
func marketplaceOptions() []marketplaceOpt {
|
||||
return []marketplaceOpt{
|
||||
{"ebay.com", "eBay (US)"},
|
||||
{"ebay.co.uk", "eBay (UK)"},
|
||||
{"ebay.de", "eBay (DE)"},
|
||||
{"ebay.fr", "eBay (FR)"},
|
||||
{"ebay.com.au", "eBay (AU)"},
|
||||
{"ebay.ca", "eBay (CA)"},
|
||||
{"yahoo.co.jp", "Yahoo Auctions JP"},
|
||||
{"mercari.jp", "Mercari JP"},
|
||||
}
|
||||
}
|
||||
|
||||
func isKnownMarketplace(v string) bool {
|
||||
for _, o := range marketplaceOptions() {
|
||||
if o.Value == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func itemHasMarketplace(it models.Item, v string) bool {
|
||||
for _, m := range it.Marketplaces {
|
||||
if m == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// customMarketplacesCSV returns the comma-separated list of marketplaces on
|
||||
// the item that are NOT in the curated list, so the user can keep editing
|
||||
// unusual values without losing them.
|
||||
func customMarketplacesCSV(it models.Item) string {
|
||||
var custom []string
|
||||
for _, m := range it.Marketplaces {
|
||||
if !isKnownMarketplace(m) {
|
||||
custom = append(custom, m)
|
||||
}
|
||||
}
|
||||
return joinCSV(custom)
|
||||
}
|
||||
|
||||
func joinCSV(vs []string) string {
|
||||
out := ""
|
||||
for i, v := range vs {
|
||||
if i > 0 {
|
||||
out += ", "
|
||||
}
|
||||
out += v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func itemHasJapanMarketplace(it models.Item) bool {
|
||||
for _, m := range it.Marketplaces {
|
||||
if m == "yahoo.co.jp" || m == "mercari.jp" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func newCategory(v string, known []string) string {
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
for _, c := range known {
|
||||
if c == v {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func itemFormBody(d ItemFormData) 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_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
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\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.IsEdit {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "Edit ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 104, Col: 22}
|
||||
}
|
||||
_, 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")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</h1>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(d.Errors) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"v-flash-error\"><ul class=\"list-disc pl-5\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, e := range d.Errors {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<li>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(e)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 113, Col: 13}
|
||||
}
|
||||
_, 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, 7, "</li>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</ul></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = itemFormInner(d).Render(ctx, templ_7745c5c3_Buffer)
|
||||
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
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func itemFormInner(d ItemFormData) 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_Var4 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var4 == nil {
|
||||
templ_7745c5c3_Var4 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<form method=\"post\" action=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 templ.SafeURL
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(formAction(d))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 125, Col: 24}
|
||||
}
|
||||
_, 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, 11, "\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !d.IsEdit {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " hx-post=\"/items/preview\" hx-target=\"#preview-target\" hx-swap=\"innerHTML\" hx-indicator=\"#preview-loading\" hx-disabled-elt=\"find button[type='submit']\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " class=\"v-card p-6 space-y-4 max-w-3xl\">")
|
||||
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, 14, "<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: 139, Col: 58}
|
||||
}
|
||||
_, 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, 15, "\" required></div><div><label class=\"v-label\">Category</label> <select class=\"v-select\" name=\"category\"><option value=\"\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.Item.Category == "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, ">— none —</option> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, c := range d.Categories {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(c)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 146, Col: 23}
|
||||
}
|
||||
_, 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, 19, "\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.Item.Category == c {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, ">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(c)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 146, Col: 64}
|
||||
}
|
||||
_, 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, 22, "</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</select> <input class=\"v-input mt-2 text-sm\" name=\"category_new\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(newCategory(d.Item.Category, d.Categories))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 149, Col: 110}
|
||||
}
|
||||
_, 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, 24, "\" placeholder=\"New category (overrides above)\"></div></div><div><label class=\"v-label\">Search Queries</label> <textarea class=\"v-textarea\" name=\"search_query\" rows=\"4\" lang=\"ja\" placeholder=\"One per line, or comma / semicolon separated\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.SearchQuery)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 154, Col: 150}
|
||||
}
|
||||
_, 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, 25, "</textarea><div class=\"v-muted text-xs mt-1\">Each is searched independently; the lowest price across all wins. Cost scales with the number of queries.</div></div><div><label class=\"v-label\">Product URL</label> <input class=\"v-input\" name=\"url\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.URL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 159, Col: 55}
|
||||
}
|
||||
_, 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=\"")
|
||||
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: 165, Col: 119}
|
||||
}
|
||||
_, 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, 27, "\"><div class=\"v-muted text-xs mt-1\">Blank = alert on every new result.</div></div><div><label class=\"v-label\">Ntfy Topic</label> <input class=\"v-input\" name=\"ntfy_topic\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.NtfyTopic)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 170, Col: 69}
|
||||
}
|
||||
_, 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, 28, "\" required></div><div><label class=\"v-label\">Ntfy Priority</label> <select class=\"v-select\" name=\"ntfy_priority\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, p := range []string{"min", "low", "default", "high", "urgent"} {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(p)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 176, Col: 23}
|
||||
}
|
||||
_, 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, 30, "\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if itemSelected(d.Item.NtfyPriority, p) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, ">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(p)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 176, Col: 80}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, opt := range pollOptions() {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", opt.Minutes))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 186, Col: 52}
|
||||
}
|
||||
_, 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, 36, "\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.Item.PollIntervalMinutes == opt.Minutes {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, ">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 186, Col: 122}
|
||||
}
|
||||
_, 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, 39, "</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</select></div><div><label class=\"v-label\">Marketplaces</label><div id=\"marketplace-grid\" class=\"grid grid-cols-2 gap-1\" onchange=\"document.getElementById('marketplace-jp-note').hidden = !document.querySelector('#marketplace-grid input[value="yahoo.co.jp"]:checked, #marketplace-grid input[value="mercari.jp"]:checked')\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, m := range marketplaceOptions() {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<label class=\"flex items-center gap-2 text-sm\"><input type=\"checkbox\" name=\"marketplace\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.Value)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 195, Col: 64}
|
||||
}
|
||||
_, 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, 42, "\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if itemHasMarketplace(d.Item, m.Value) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, " checked")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "> <span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(m.Label)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 196, Col: 22}
|
||||
}
|
||||
_, 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, 45, "</span></label>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</div><input class=\"v-input mt-2 text-sm\" name=\"marketplace_custom\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(customMarketplacesCSV(d.Item))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 200, Col: 103}
|
||||
}
|
||||
_, 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, 47, "\" placeholder=\"Custom (comma-separated, added to above)\"><div id=\"marketplace-jp-note\" class=\"v-flash mt-2 text-sm\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !itemHasJapanMarketplace(d.Item) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " hidden")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, ">Yahoo Auctions JP and Mercari JP search in Japanese. English queries return few or no results.</div><div class=\"v-muted text-xs mt-1\">Pick one or more. Each is polled per cycle; one failure does not stop the others.</div></div><div><label class=\"v-label\">Listing Type</label> <select class=\"v-select\" name=\"listing_type\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, lt := range []string{"all", "BIN", "auction"} {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(lt)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 212, Col: 24}
|
||||
}
|
||||
_, 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, 51, "\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if itemSelected(d.Item.ListingType, lt) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(lt)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 212, Col: 82}
|
||||
}
|
||||
_, 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, 54, "</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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=\"")
|
||||
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: 220, Col: 113}
|
||||
}
|
||||
_, 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, 56, "\"><div class=\"v-muted text-xs mt-1\">Drop results below this price. Filters out accessories and obvious junk.</div></div><div><label class=\"v-label\">Exclude Keywords</label> <textarea class=\"v-textarea\" name=\"exclude_keywords\" rows=\"3\" placeholder=\"One per line, or comma separated\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.ExcludeKeywords)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 225, Col: 137}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</textarea><div class=\"v-muted text-xs mt-1\">Drop results whose title contains any of these. Case-insensitive substring match.</div></div></div><label class=\"flex items-center gap-2\"><input type=\"checkbox\" name=\"include_out_of_stock\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.Item.IncludeOutOfStock {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " checked")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " value=\"1\"> <span>Include out-of-stock results</span></label> <details class=\"v-card-flat p-4\"><summary class=\"cursor-pointer font-semibold\">Advanced</summary><div class=\"v-muted text-sm mt-2\">Leave blank to use the configured default for the selected marketplace.</div><div class=\"grid md:grid-cols-2 gap-4 mt-3\"><div><label class=\"v-label\">Active Listings Actor</label> <input class=\"v-input\" name=\"actor_active\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.ActorActive)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 239, Col: 74}
|
||||
}
|
||||
_, 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, 60, "\" placeholder=\"from config\"></div><div><label class=\"v-label\">Sold Listings Actor</label> <input class=\"v-input\" name=\"actor_sold\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var26 string
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.ActorSold)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 243, Col: 70}
|
||||
}
|
||||
_, 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, 61, "\" placeholder=\"from config\"></div><div><label class=\"v-label\">Price Comparison Actor</label> <input class=\"v-input\" name=\"actor_price_compare\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var27 string
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.ActorPriceCompare)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 247, Col: 87}
|
||||
}
|
||||
_, 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, 62, "\" placeholder=\"from config\"></div><label class=\"flex items-center gap-2 mt-6\"><input type=\"checkbox\" name=\"use_price_comparison\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.Item.UsePriceComparison {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, " checked")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " value=\"1\"> <span>Use price comparison actor in addition to active listings</span></label></div></details><div class=\"flex items-center gap-3 pt-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.IsEdit {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<button class=\"v-btn\" type=\"submit\">Save</button> <a class=\"v-btn-ghost\" href=\"/items\">Cancel</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<button class=\"v-btn\" type=\"submit\">Preview</button> <a class=\"v-btn-ghost\" href=\"/items\">Cancel</a> <span id=\"preview-loading\" class=\"htmx-indicator v-muted text-sm flex items-center gap-2\"><span class=\"v-spinner\"></span> Running preview… apify runs can take 30–60s.</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</div></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !d.IsEdit {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<div id=\"preview-target\" class=\"mt-6\"></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func formAction(d ItemFormData) templ.SafeURL {
|
||||
if d.IsEdit {
|
||||
return templ.SafeURL(fmt.Sprintf("/items/%d", d.Item.ID))
|
||||
}
|
||||
return templ.SafeURL("/items/preview")
|
||||
}
|
||||
|
||||
type pollOpt struct {
|
||||
Minutes int
|
||||
Label string
|
||||
}
|
||||
|
||||
func pollOptions() []pollOpt {
|
||||
return []pollOpt{
|
||||
{15, "15 min"}, {30, "30 min"}, {60, "1 hr"}, {120, "2 hr"},
|
||||
{360, "6 hr"}, {720, "12 hr"}, {1440, "24 hr"},
|
||||
}
|
||||
}
|
||||
|
||||
func optFloat(p *float64) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%.2f", *p)
|
||||
}
|
||||
|
||||
func ItemForm(d ItemFormData) 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_Var28 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var28 == nil {
|
||||
templ_7745c5c3_Var28 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = Layout(d.Page, itemFormBody(d)).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
163
templates/item_preview.templ
Normal file
163
templates/item_preview.templ
Normal file
@@ -0,0 +1,163 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"veola/internal/apify"
|
||||
)
|
||||
|
||||
type PreviewData struct {
|
||||
CSRFToken string
|
||||
Form FormValues
|
||||
Results []apify.UnifiedResult
|
||||
BestIndex int
|
||||
MinPrice float64
|
||||
MaxPrice float64
|
||||
StoreCount int
|
||||
Error string
|
||||
Empty bool
|
||||
Cached bool
|
||||
Currency string
|
||||
}
|
||||
|
||||
// FormValues mirrors the Step 1 form so the confirm POST has every field.
|
||||
type FormValues struct {
|
||||
Name string
|
||||
SearchQuery string
|
||||
URL string
|
||||
Category string
|
||||
TargetPrice string
|
||||
MinPrice string
|
||||
ExcludeKeywords string
|
||||
NtfyTopic string
|
||||
NtfyPriority string
|
||||
PollIntervalMinutes string
|
||||
IncludeOutOfStock bool
|
||||
Marketplaces []string
|
||||
ListingType string
|
||||
ActorActive string
|
||||
ActorSold string
|
||||
ActorPriceCompare string
|
||||
UsePriceComparison bool
|
||||
}
|
||||
|
||||
templ ItemPreview(d PreviewData) {
|
||||
if d.Error != "" {
|
||||
<div class="v-flash-error">
|
||||
<div class="font-semibold mb-1">Could not run preview</div>
|
||||
<div>{ d.Error }</div>
|
||||
<button class="v-btn-ghost mt-2" hx-get="/items/new" hx-target="#preview-target" hx-swap="innerHTML">Back</button>
|
||||
</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>
|
||||
} else {
|
||||
<div class="v-card p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="font-semibold">
|
||||
{ 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>
|
||||
}
|
||||
</h2>
|
||||
</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">
|
||||
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>
|
||||
</div>
|
||||
<div class="font-mono">{ fmtNumber(r.Price, r.Currency) }</div>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
if len(d.Results) > 6 {
|
||||
<div class="v-muted text-xs mt-2">{ fmt.Sprintf("and %d more", len(d.Results)-6) }</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="v-divider my-4"></div>
|
||||
<div class="text-sm v-muted">
|
||||
{ fmt.Sprintf("Prices range from %s to %s across %d stores", fmtNumber(d.MinPrice, d.Currency), fmtNumber(d.MaxPrice, d.Currency), d.StoreCount) }
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@confirmForm(d)
|
||||
}
|
||||
|
||||
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"/>
|
||||
} else {
|
||||
<div class="rounded w-40 h-40 bg-black/30"></div>
|
||||
}
|
||||
<div>
|
||||
<div class="text-xs v-price-deal uppercase tracking-wide 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>
|
||||
if d.Results[d.BestIndex].MatchedQuery != "" {
|
||||
<div class="v-muted text-xs mt-1">via "{ d.Results[d.BestIndex].MatchedQuery }"</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ confirmForm(d PreviewData) {
|
||||
<form method="post" action="/items" class="mt-5 flex items-center gap-3">
|
||||
<input type="hidden" name="csrf_token" value={ d.CSRFToken }/>
|
||||
@hidden("name", d.Form.Name)
|
||||
@hidden("search_query", d.Form.SearchQuery)
|
||||
@hidden("url", d.Form.URL)
|
||||
@hidden("category", d.Form.Category)
|
||||
@hidden("target_price", d.Form.TargetPrice)
|
||||
@hidden("min_price", d.Form.MinPrice)
|
||||
@hidden("exclude_keywords", d.Form.ExcludeKeywords)
|
||||
@hidden("ntfy_topic", d.Form.NtfyTopic)
|
||||
@hidden("ntfy_priority", d.Form.NtfyPriority)
|
||||
@hidden("poll_interval_minutes", d.Form.PollIntervalMinutes)
|
||||
@hiddenBool("include_out_of_stock", d.Form.IncludeOutOfStock)
|
||||
for _, m := range d.Form.Marketplaces {
|
||||
@hidden("marketplace", m)
|
||||
}
|
||||
@hidden("listing_type", d.Form.ListingType)
|
||||
@hidden("actor_active", d.Form.ActorActive)
|
||||
@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>
|
||||
<a class="v-btn-ghost" href="/items/new">Back</a>
|
||||
</form>
|
||||
}
|
||||
|
||||
templ hidden(name, value string) {
|
||||
<input type="hidden" name={ name } value={ value }/>
|
||||
}
|
||||
|
||||
templ hiddenBool(name string, value bool) {
|
||||
if value {
|
||||
<input type="hidden" name={ name } value="1"/>
|
||||
}
|
||||
}
|
||||
|
||||
func fmtNumber(p float64, currency string) string {
|
||||
if p == 0 {
|
||||
return "—"
|
||||
}
|
||||
return fmt.Sprintf("%s%.2f", currencySymbol(currency), p)
|
||||
}
|
||||
636
templates/item_preview_templ.go
Normal file
636
templates/item_preview_templ.go
Normal file
@@ -0,0 +1,636 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"veola/internal/apify"
|
||||
)
|
||||
|
||||
type PreviewData struct {
|
||||
CSRFToken string
|
||||
Form FormValues
|
||||
Results []apify.UnifiedResult
|
||||
BestIndex int
|
||||
MinPrice float64
|
||||
MaxPrice float64
|
||||
StoreCount int
|
||||
Error string
|
||||
Empty bool
|
||||
Cached bool
|
||||
Currency string
|
||||
}
|
||||
|
||||
// FormValues mirrors the Step 1 form so the confirm POST has every field.
|
||||
type FormValues struct {
|
||||
Name string
|
||||
SearchQuery string
|
||||
URL string
|
||||
Category string
|
||||
TargetPrice string
|
||||
MinPrice string
|
||||
ExcludeKeywords string
|
||||
NtfyTopic string
|
||||
NtfyPriority string
|
||||
PollIntervalMinutes string
|
||||
IncludeOutOfStock bool
|
||||
Marketplaces []string
|
||||
ListingType string
|
||||
ActorActive string
|
||||
ActorSold string
|
||||
ActorPriceCompare string
|
||||
UsePriceComparison bool
|
||||
}
|
||||
|
||||
func ItemPreview(d PreviewData) 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_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
if d.Error != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"v-flash-error\"><div class=\"font-semibold mb-1\">Could not run preview</div><div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d.Error)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 48, Col: 17}
|
||||
}
|
||||
_, 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><button class=\"v-btn-ghost mt-2\" hx-get=\"/items/new\" hx-target=\"#preview-target\" hx-swap=\"innerHTML\">Back</button></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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>")
|
||||
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\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Found %d results for '%s'", len(d.Results), d.Form.SearchQuery))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 60, Col: 83}
|
||||
}
|
||||
_, 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, 5, " ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.Cached {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<span class=\"v-pill v-pill-paused ml-2\">cached</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</h2></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = previewBest(d).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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\">")
|
||||
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\">")
|
||||
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: 75, 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=\"")
|
||||
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))
|
||||
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_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\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 78, Col: 99}
|
||||
}
|
||||
_, 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\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(r.Store)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 79, Col: 48}
|
||||
}
|
||||
_, 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: 81, 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>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</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\">")
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div>")
|
||||
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
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = confirmForm(d).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func previewBest(d PreviewData) 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_Var11 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var11 == nil {
|
||||
templ_7745c5c3_Var11 = 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\">")
|
||||
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=\"")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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)
|
||||
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\">")
|
||||
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>")
|
||||
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\">")
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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_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=\"")
|
||||
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))
|
||||
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_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" target=\"_blank\" rel=\"noopener\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(d.Results[d.BestIndex].Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 111, Col: 141}
|
||||
}
|
||||
_, 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: 112, 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>")
|
||||
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 \"")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\"</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func confirmForm(d PreviewData) 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_Var18 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var18 == nil {
|
||||
templ_7745c5c3_Var18 = 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=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 123, Col: 60}
|
||||
}
|
||||
_, 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, 36, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = hidden("name", d.Form.Name).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = hidden("search_query", d.Form.SearchQuery).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = hidden("url", d.Form.URL).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = hidden("category", d.Form.Category).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = hidden("target_price", d.Form.TargetPrice).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = hidden("min_price", d.Form.MinPrice).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = hidden("exclude_keywords", d.Form.ExcludeKeywords).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = hidden("ntfy_topic", d.Form.NtfyTopic).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = hidden("ntfy_priority", d.Form.NtfyPriority).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = hidden("poll_interval_minutes", d.Form.PollIntervalMinutes).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = hiddenBool("include_out_of_stock", d.Form.IncludeOutOfStock).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, m := range d.Form.Marketplaces {
|
||||
templ_7745c5c3_Err = hidden("marketplace", m).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = hidden("listing_type", d.Form.ListingType).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = hidden("actor_active", d.Form.ActorActive).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = hidden("actor_sold", d.Form.ActorSold).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = hidden("actor_price_compare", d.Form.ActorPriceCompare).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = hiddenBool("use_price_comparison", d.Form.UsePriceComparison).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<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 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>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func hidden(name, 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_Var20 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var20 == nil {
|
||||
templ_7745c5c3_Var20 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<input type=\"hidden\" name=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 149, Col: 33}
|
||||
}
|
||||
_, 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: 149, 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, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func hiddenBool(name string, value bool) 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_Var23 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var23 == nil {
|
||||
templ_7745c5c3_Var23 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
if value {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 154, Col: 34}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" value=\"1\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func fmtNumber(p float64, currency string) string {
|
||||
if p == 0 {
|
||||
return "—"
|
||||
}
|
||||
return fmt.Sprintf("%s%.2f", currencySymbol(currency), p)
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
162
templates/items.templ
Normal file
162
templates/items.templ
Normal file
@@ -0,0 +1,162 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"veola/internal/models"
|
||||
)
|
||||
|
||||
type ItemsData struct {
|
||||
Page
|
||||
Items []models.Item
|
||||
Categories []string
|
||||
SelectedCategory string
|
||||
}
|
||||
|
||||
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>
|
||||
if len(d.Categories) > 0 {
|
||||
<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>
|
||||
for _, c := range d.Categories {
|
||||
<option value={ c } selected?={ c == d.SelectedCategory }>{ c }</option>
|
||||
}
|
||||
</select>
|
||||
</form>
|
||||
}
|
||||
if len(d.Items) == 0 {
|
||||
@itemsEmpty()
|
||||
} else {
|
||||
<div class="v-card p-0 overflow-hidden">
|
||||
<table class="v-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Category</th>
|
||||
<th>Target</th>
|
||||
<th>Best Price</th>
|
||||
<th>Last Polled</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="items-tbody">
|
||||
for _, it := range d.Items {
|
||||
@itemRow(it, d.CSRFToken)
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ itemsEmpty() {
|
||||
<div class="v-card p-8 flex flex-col md:flex-row items-center gap-6">
|
||||
<div class="v-veola-portrait w-48 shrink-0">
|
||||
<img src="/static/img/veola.webp" alt="Veola"/>
|
||||
</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 itemRow(it models.Item, csrf string) {
|
||||
<tr id={ fmt.Sprintf("item-row-%d", it.ID) }>
|
||||
<td>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/items/%d/results", it.ID)) }>{ it.Name }</a>
|
||||
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">
|
||||
if it.TargetPrice != nil {
|
||||
{ fmtPrice(it.TargetPrice, "USD") }
|
||||
} else {
|
||||
<span class="v-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
if it.BestPrice != nil {
|
||||
<div class={ "font-mono text-lg", priceClass(it.BestPrice, it.TargetPrice) }>{ fmtPrice(it.BestPrice, "USD") }</div>
|
||||
if it.BestPriceURL != "" {
|
||||
<a class="text-xs" href={ templ.SafeURL(it.BestPriceURL) } target="_blank" rel="noopener">{ it.BestPriceStore }</a>
|
||||
} else if it.BestPriceStore != "" {
|
||||
<span class="text-xs v-muted">{ it.BestPriceStore }</span>
|
||||
}
|
||||
} else {
|
||||
<span class="v-muted">not yet</span>
|
||||
}
|
||||
</td>
|
||||
<td class="v-muted text-sm">
|
||||
if it.LastPolledAt != nil {
|
||||
<span title={ it.LastPolledAt.Format("2006-01-02 15:04:05") }>{ humanTime(*it.LastPolledAt) }</span>
|
||||
} else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
if it.Active {
|
||||
<span class="v-pill v-pill-active">active</span>
|
||||
} else {
|
||||
<span class="v-pill v-pill-paused">paused</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-right whitespace-nowrap">
|
||||
<form class="inline" hx-post={ fmt.Sprintf("/items/%d/toggle", it.ID) } hx-target={ fmt.Sprintf("#item-row-%d", it.ID) } hx-swap="outerHTML">
|
||||
<input type="hidden" name="csrf_token" value={ csrf }/>
|
||||
<button class="v-btn-ghost text-sm" type="submit">
|
||||
if it.Active {
|
||||
Pause
|
||||
} else {
|
||||
Resume
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
<form class="inline" hx-post={ fmt.Sprintf("/items/%d/run", it.ID) } hx-target={ fmt.Sprintf("#item-row-%d", it.ID) } hx-swap="outerHTML">
|
||||
<input type="hidden" name="csrf_token" value={ csrf }/>
|
||||
<button class="v-btn-ghost text-sm" type="submit">Run Now</button>
|
||||
</form>
|
||||
<a class="v-btn-ghost text-sm" href={ templ.SafeURL(fmt.Sprintf("/items/%d/edit", it.ID)) }>Edit</a>
|
||||
<form class="inline" hx-post={ fmt.Sprintf("/items/%d/delete", it.ID) } hx-target={ fmt.Sprintf("#item-row-%d", it.ID) } hx-swap="outerHTML" hx-confirm="Delete this item?">
|
||||
<input type="hidden" name="csrf_token" value={ csrf }/>
|
||||
<button class="v-btn-ghost text-sm" type="submit">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
func priceClass(best, target *float64) string {
|
||||
if best == nil || target == nil {
|
||||
return ""
|
||||
}
|
||||
if *best <= *target {
|
||||
return "v-price-target"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
templ Items(d ItemsData) {
|
||||
@Layout(d.Page, itemsBody(d))
|
||||
}
|
||||
|
||||
// ItemRow renders a single row partial, used by HTMX endpoints.
|
||||
templ ItemRow(it models.Item, csrf string) {
|
||||
@itemRow(it, csrf)
|
||||
}
|
||||
|
||||
// EmptyRow lets a delete handler return a row replacement that vanishes.
|
||||
templ EmptyRow() {
|
||||
<tr></tr>
|
||||
}
|
||||
705
templates/items_templ.go
Normal file
705
templates/items_templ.go
Normal file
@@ -0,0 +1,705 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"veola/internal/models"
|
||||
)
|
||||
|
||||
type ItemsData struct {
|
||||
Page
|
||||
Items []models.Item
|
||||
Categories []string
|
||||
SelectedCategory string
|
||||
}
|
||||
|
||||
func itemsBody(d ItemsData) 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_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
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>")
|
||||
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> ")
|
||||
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: 28, 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, ">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(c)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 28, Col: 67}
|
||||
}
|
||||
_, 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, 7, "</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</select></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if len(d.Items) == 0 {
|
||||
templ_7745c5c3_Err = itemsEmpty().Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"v-card p-0 overflow-hidden\"><table class=\"v-table\"><thead><tr><th>Name</th><th>Category</th><th>Target</th><th>Best Price</th><th>Last Polled</th><th>Status</th><th></th></tr></thead> <tbody id=\"items-tbody\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, it := range d.Items {
|
||||
templ_7745c5c3_Err = itemRow(it, d.CSRFToken).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</tbody></table></div>")
|
||||
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
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func itemsEmpty() 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_Var4 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var4 == nil {
|
||||
templ_7745c5c3_Var4 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"v-card p-8 flex flex-col md:flex-row items-center gap-6\"><div class=\"v-veola-portrait w-48 shrink-0\"><img src=\"/static/img/veola.webp\" alt=\"Veola\"></div><div><h2 class=\"text-xl font-semibold mb-2\">Nothing on the watchlist.</h2><p class=\"v-muted mb-4\">Add an item and Veola will keep an eye on it.</p><a class=\"v-btn\" href=\"/items/new\">Add the first item</a></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func itemRow(it models.Item, 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 {
|
||||
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_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, 13, "<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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 74, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\"><td><a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 templ.SafeURL
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results", it.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 76, Col: 67}
|
||||
}
|
||||
_, 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, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(it.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 76, Col: 79}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if it.LastPollError != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<button class=\"v-pill v-pill-error ml-2\" hx-get=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/items/%d/error", it.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 78, Col: 91}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" hx-target=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("#item-error-%d", it.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 78, Col: 142}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" hx-swap=\"innerHTML\">!</button><div id=\"")
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 79, Col: 49}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" class=\"v-error-text mt-1\"></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</td><td class=\"v-muted\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(it.Category)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 82, Col: 35}
|
||||
}
|
||||
_, 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, "</td><td class=\"font-mono\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if it.TargetPrice != nil {
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(it.TargetPrice, "USD"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 85, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<span class=\"v-muted\">—</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</td><td>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if it.BestPrice != nil {
|
||||
var templ_7745c5c3_Var14 = []any{"font-mono text-lg", priceClass(it.BestPrice, it.TargetPrice)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var14...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var14).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(it.BestPrice, "USD"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 92, Col: 112}
|
||||
}
|
||||
_, 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, 27, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if it.BestPriceURL != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<a class=\"text-xs\" href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 templ.SafeURL
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(it.BestPriceURL))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 94, Col: 61}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" target=\"_blank\" rel=\"noopener\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(it.BestPriceStore)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 94, Col: 114}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if it.BestPriceStore != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<span class=\"text-xs v-muted\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(it.BestPriceStore)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 96, Col: 54}
|
||||
}
|
||||
_, 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, 32, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<span class=\"v-muted\">not yet</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</td><td class=\"v-muted text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if it.LastPolledAt != nil {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<span title=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(it.LastPolledAt.Format("2006-01-02 15:04:05"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 104, Col: 63}
|
||||
}
|
||||
_, 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, 36, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(*it.LastPolledAt))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 104, Col: 95}
|
||||
}
|
||||
_, 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, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "—")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</td><td>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if it.Active {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<span class=\"v-pill v-pill-active\">active</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<span class=\"v-pill v-pill-paused\">paused</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</td><td class=\"text-right whitespace-nowrap\"><form class=\"inline\" hx-post=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/items/%d/toggle", it.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 117, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" hx-target=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("#item-row-%d", it.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 117, Col: 121}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" hx-swap=\"outerHTML\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrf)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 118, Col: 55}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\"> <button class=\"v-btn-ghost text-sm\" type=\"submit\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if it.Active {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "Pause")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "Resume")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</button></form><form class=\"inline\" hx-post=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/items/%d/run", it.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 127, Col: 69}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\" hx-target=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var26 string
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("#item-row-%d", it.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 127, Col: 118}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" hx-swap=\"outerHTML\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var27 string
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrf)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 128, Col: 55}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\"> <button class=\"v-btn-ghost text-sm\" type=\"submit\">Run Now</button></form><a class=\"v-btn-ghost text-sm\" href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var28 templ.SafeURL
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/edit", it.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 131, Col: 92}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\">Edit</a><form class=\"inline\" hx-post=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/items/%d/delete", it.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 132, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" hx-target=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("#item-row-%d", it.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 132, Col: 121}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\" hx-swap=\"outerHTML\" hx-confirm=\"Delete this item?\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrf)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 133, Col: 55}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\"> <button class=\"v-btn-ghost text-sm\" type=\"submit\">Delete</button></form></td></tr>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func priceClass(best, target *float64) string {
|
||||
if best == nil || target == nil {
|
||||
return ""
|
||||
}
|
||||
if *best <= *target {
|
||||
return "v-price-target"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func Items(d ItemsData) 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_Var32 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var32 == nil {
|
||||
templ_7745c5c3_Var32 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = Layout(d.Page, itemsBody(d)).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ItemRow renders a single row partial, used by HTMX endpoints.
|
||||
func ItemRow(it models.Item, 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 {
|
||||
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_Var33 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var33 == nil {
|
||||
templ_7745c5c3_Var33 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = itemRow(it, csrf).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// EmptyRow lets a delete handler return a row replacement that vanishes.
|
||||
func EmptyRow() 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_Var34 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var34 == nil {
|
||||
templ_7745c5c3_Var34 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<tr></tr>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
86
templates/layout.templ
Normal file
86
templates/layout.templ
Normal file
@@ -0,0 +1,86 @@
|
||||
package templates
|
||||
|
||||
import "veola/internal/models"
|
||||
|
||||
// Page is the data carrier for any rendered page; concrete handlers populate
|
||||
// only the fields they use.
|
||||
type Page struct {
|
||||
Title string
|
||||
Active string
|
||||
CSRFToken string
|
||||
CurrentUser *models.User
|
||||
Flash string
|
||||
FlashError string
|
||||
}
|
||||
|
||||
templ head(title string) {
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>{ title } · Veola</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet"/>
|
||||
<link rel="stylesheet" href="/static/css/app.css"/>
|
||||
<script src="/static/vendor/htmx.min.js" defer></script>
|
||||
</head>
|
||||
}
|
||||
|
||||
templ Sidebar(active string) {
|
||||
<nav class="v-side-nav flex flex-col">
|
||||
<div class="px-4 py-5 flex items-center gap-2">
|
||||
<span class="text-2xl">🐝</span>
|
||||
<span class="text-xl font-semibold tracking-wide">Veola</span>
|
||||
</div>
|
||||
<a href="/" class={ navClass("dashboard", active) }>Dashboard</a>
|
||||
<a href="/items" class={ navClass("items", active) }>Items</a>
|
||||
<a href="/results" class={ navClass("results", active) }>Results</a>
|
||||
<a href="/settings" class={ navClass("settings", active) }>Settings</a>
|
||||
<div class="mt-auto px-4 py-4 v-muted text-xs">
|
||||
Track. Watch. Notice.
|
||||
</div>
|
||||
</nav>
|
||||
}
|
||||
|
||||
func navClass(key, active string) string {
|
||||
if key == active {
|
||||
return "active"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
templ Layout(p Page, body templ.Component) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@head(p.Title)
|
||||
<body class="min-h-screen flex">
|
||||
@Sidebar(p.Active)
|
||||
<main class="flex-1 p-6 max-w-6xl">
|
||||
if p.Flash != "" {
|
||||
<div class="v-flash">{ p.Flash }</div>
|
||||
}
|
||||
if p.FlashError != "" {
|
||||
<div class="v-flash-error">{ p.FlashError }</div>
|
||||
}
|
||||
@body
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
// Bare is a chrome-less layout used by /login and /setup.
|
||||
templ Bare(p Page, body templ.Component) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@head(p.Title)
|
||||
<body class="min-h-screen">
|
||||
@body
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
// CSRFInput is the hidden form field for state-changing forms.
|
||||
templ CSRFInput(token string) {
|
||||
<input type="hidden" name="csrf_token" value={ token }/>
|
||||
}
|
||||
370
templates/layout_templ.go
Normal file
370
templates/layout_templ.go
Normal file
@@ -0,0 +1,370 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import "veola/internal/models"
|
||||
|
||||
// Page is the data carrier for any rendered page; concrete handlers populate
|
||||
// only the fields they use.
|
||||
type Page struct {
|
||||
Title string
|
||||
Active string
|
||||
CSRFToken string
|
||||
CurrentUser *models.User
|
||||
Flash string
|
||||
FlashError string
|
||||
}
|
||||
|
||||
func head(title 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_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 20, Col: 16}
|
||||
}
|
||||
_, 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, " · Veola</title><script src=\"https://cdn.tailwindcss.com\"></script><link rel=\"preconnect\" href=\"https://fonts.googleapis.com\"><link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin><link href=\"https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap\" rel=\"stylesheet\"><link rel=\"stylesheet\" href=\"/static/css/app.css\"><script src=\"/static/vendor/htmx.min.js\" defer></script></head>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func Sidebar(active 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_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, "<nav class=\"v-side-nav flex flex-col\"><div class=\"px-4 py-5 flex items-center gap-2\"><span class=\"text-2xl\">🐝</span> <span class=\"text-xl font-semibold tracking-wide\">Veola</span></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 = []any{navClass("dashboard", active)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var4...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<a href=\"/\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var4).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, 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, 5, "\">Dashboard</a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 = []any{navClass("items", active)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var6...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<a href=\"/items\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var6).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, 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, 7, "\">Items</a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 = []any{navClass("results", active)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<a href=\"/results\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var8).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, 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, 9, "\">Results</a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 = []any{navClass("settings", active)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var10...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<a href=\"/settings\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var10).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, 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, 11, "\">Settings</a><div class=\"mt-auto px-4 py-4 v-muted text-xs\">Track. Watch. Notice.</div></nav>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func navClass(key, active string) string {
|
||||
if key == active {
|
||||
return "active"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func Layout(p Page, body templ.Component) 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_Var12 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var12 == nil {
|
||||
templ_7745c5c3_Var12 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<!doctype html><html lang=\"en\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = head(p.Title).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<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)
|
||||
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\">")
|
||||
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\">")
|
||||
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: 61, 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>")
|
||||
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\">")
|
||||
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: 64, 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>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = body.Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</main></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Bare is a chrome-less layout used by /login and /setup.
|
||||
func Bare(p Page, body templ.Component) 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_Var15 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var15 == nil {
|
||||
templ_7745c5c3_Var15 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<!doctype html><html lang=\"en\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = head(p.Title).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<body class=\"min-h-screen\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = body.Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// CSRFInput is the hidden form field for state-changing forms.
|
||||
func CSRFInput(token 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_Var16 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var16 == nil {
|
||||
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=\"")
|
||||
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: 85, 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, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
95
templates/login.templ
Normal file
95
templates/login.templ
Normal file
@@ -0,0 +1,95 @@
|
||||
package templates
|
||||
|
||||
type LoginData struct {
|
||||
Page
|
||||
Error string
|
||||
Username string
|
||||
}
|
||||
|
||||
templ loginBody(d LoginData) {
|
||||
<div class="min-h-screen grid md:grid-cols-2">
|
||||
<div class="flex items-center justify-center p-8">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="flex items-center gap-2 mb-6">
|
||||
<span class="text-3xl">🐝</span>
|
||||
<span class="text-2xl font-semibold tracking-wide">Veola</span>
|
||||
</div>
|
||||
<h1 class="text-3xl font-semibold mb-2">Open the door.</h1>
|
||||
<p class="v-muted mb-6">Sign in to continue.</p>
|
||||
if d.Error != "" {
|
||||
<div class="v-flash-error">{ d.Error }</div>
|
||||
}
|
||||
<form method="post" action="/login" class="space-y-4">
|
||||
@CSRFInput(d.CSRFToken)
|
||||
<div>
|
||||
<label class="v-label">Username</label>
|
||||
<input class="v-input" name="username" autocomplete="username" autofocus value={ d.Username }/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="v-label">Password</label>
|
||||
<input class="v-input" type="password" name="password" autocomplete="current-password"/>
|
||||
</div>
|
||||
<button class="v-btn w-full justify-center" type="submit">Sign In</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden md:flex items-end justify-center p-8 bg-[#152560]">
|
||||
<div class="v-veola-portrait max-w-md">
|
||||
<img src="/static/img/veola.webp" alt="Veola"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ Login(d LoginData) {
|
||||
@Bare(d.Page, loginBody(d))
|
||||
}
|
||||
|
||||
type SetupData struct {
|
||||
Page
|
||||
Error string
|
||||
Username string
|
||||
}
|
||||
|
||||
templ setupBody(d SetupData) {
|
||||
<div class="min-h-screen grid md:grid-cols-2">
|
||||
<div class="flex items-center justify-center p-8">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="flex items-center gap-2 mb-6">
|
||||
<span class="text-3xl">🐝</span>
|
||||
<span class="text-2xl font-semibold tracking-wide">Veola</span>
|
||||
</div>
|
||||
<h1 class="text-3xl font-semibold mb-2">First time here.</h1>
|
||||
<p class="v-muted mb-6">Create the admin account. Password must be at least 12 characters.</p>
|
||||
if d.Error != "" {
|
||||
<div class="v-flash-error">{ d.Error }</div>
|
||||
}
|
||||
<form method="post" action="/setup" class="space-y-4">
|
||||
@CSRFInput(d.CSRFToken)
|
||||
<div>
|
||||
<label class="v-label">Username</label>
|
||||
<input class="v-input" name="username" autofocus value={ d.Username }/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="v-label">Password</label>
|
||||
<input class="v-input" type="password" name="password"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="v-label">Confirm Password</label>
|
||||
<input class="v-input" type="password" name="password_confirm"/>
|
||||
</div>
|
||||
<button class="v-btn w-full justify-center" type="submit">Create Admin</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden md:flex items-end justify-center p-8 bg-[#152560]">
|
||||
<div class="v-veola-portrait max-w-md">
|
||||
<img src="/static/img/veola.webp" alt="Veola"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ Setup(d SetupData) {
|
||||
@Bare(d.Page, setupBody(d))
|
||||
}
|
||||
227
templates/login_templ.go
Normal file
227
templates/login_templ.go
Normal file
@@ -0,0 +1,227 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
type LoginData struct {
|
||||
Page
|
||||
Error string
|
||||
Username string
|
||||
}
|
||||
|
||||
func loginBody(d LoginData) 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_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"min-h-screen grid md:grid-cols-2\"><div class=\"flex items-center justify-center p-8\"><div class=\"w-full max-w-sm\"><div class=\"flex items-center gap-2 mb-6\"><span class=\"text-3xl\">🐝</span> <span class=\"text-2xl font-semibold tracking-wide\">Veola</span></div><h1 class=\"text-3xl font-semibold mb-2\">Open the door.</h1><p class=\"v-muted mb-6\">Sign in to continue.</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.Error != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"v-flash-error\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d.Error)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 20, Col: 41}
|
||||
}
|
||||
_, 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>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<form method=\"post\" action=\"/login\" 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, 5, "<div><label class=\"v-label\">Username</label> <input class=\"v-input\" name=\"username\" autocomplete=\"username\" autofocus value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Username)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 26, Col: 97}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\"></div><div><label class=\"v-label\">Password</label> <input class=\"v-input\" type=\"password\" name=\"password\" autocomplete=\"current-password\"></div><button class=\"v-btn w-full justify-center\" type=\"submit\">Sign In</button></form></div></div><div class=\"hidden md:flex items-end justify-center p-8 bg-[#152560]\"><div class=\"v-veola-portrait max-w-md\"><img src=\"/static/img/veola.webp\" alt=\"Veola\"></div></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func Login(d LoginData) 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_Var4 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var4 == nil {
|
||||
templ_7745c5c3_Var4 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = Bare(d.Page, loginBody(d)).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
type SetupData struct {
|
||||
Page
|
||||
Error string
|
||||
Username string
|
||||
}
|
||||
|
||||
func setupBody(d SetupData) 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_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, 7, "<div class=\"min-h-screen grid md:grid-cols-2\"><div class=\"flex items-center justify-center p-8\"><div class=\"w-full max-w-sm\"><div class=\"flex items-center gap-2 mb-6\"><span class=\"text-3xl\">🐝</span> <span class=\"text-2xl font-semibold tracking-wide\">Veola</span></div><h1 class=\"text-3xl font-semibold mb-2\">First time here.</h1><p class=\"v-muted mb-6\">Create the admin account. Password must be at least 12 characters.</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.Error != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"v-flash-error\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(d.Error)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 65, Col: 41}
|
||||
}
|
||||
_, 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, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<form method=\"post\" action=\"/setup\" 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, 11, "<div><label class=\"v-label\">Username</label> <input class=\"v-input\" name=\"username\" autofocus value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Username)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 71, Col: 73}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"></div><div><label class=\"v-label\">Password</label> <input class=\"v-input\" type=\"password\" name=\"password\"></div><div><label class=\"v-label\">Confirm Password</label> <input class=\"v-input\" type=\"password\" name=\"password_confirm\"></div><button class=\"v-btn w-full justify-center\" type=\"submit\">Create Admin</button></form></div></div><div class=\"hidden md:flex items-end justify-center p-8 bg-[#152560]\"><div class=\"v-veola-portrait max-w-md\"><img src=\"/static/img/veola.webp\" alt=\"Veola\"></div></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func Setup(d SetupData) 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_Var8 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var8 == nil {
|
||||
templ_7745c5c3_Var8 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = Bare(d.Page, setupBody(d)).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
266
templates/results.templ
Normal file
266
templates/results.templ
Normal file
@@ -0,0 +1,266 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"veola/internal/models"
|
||||
)
|
||||
|
||||
type ItemResultsData struct {
|
||||
Page
|
||||
Item models.Item
|
||||
Badge BadgeData
|
||||
History []models.PricePoint
|
||||
Results []models.Result
|
||||
Page_ int
|
||||
TotalPages int
|
||||
Order string
|
||||
HistoryChartJSON string
|
||||
}
|
||||
|
||||
type BadgeData struct {
|
||||
Label string
|
||||
Class string // v-badge-low / v-badge-avg / v-badge-target / ""
|
||||
}
|
||||
|
||||
type GlobalResultsData struct {
|
||||
Page
|
||||
Items []models.Item
|
||||
Results []ItemResultRow
|
||||
ItemID int64
|
||||
From string
|
||||
To string
|
||||
}
|
||||
|
||||
type ItemResultRow struct {
|
||||
models.Result
|
||||
ItemName string
|
||||
}
|
||||
|
||||
templ itemResultsBody(d ItemResultsData) {
|
||||
<div>
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-semibold">{ d.Item.Name }</h1>
|
||||
if d.Item.Category != "" {
|
||||
<div class="v-muted">{ d.Item.Category }</div>
|
||||
}
|
||||
</div>
|
||||
<div class="text-right">
|
||||
if d.Item.BestPrice != nil {
|
||||
<div class="font-mono text-3xl">{ fmtPrice(d.Item.BestPrice, "USD") }</div>
|
||||
if d.Item.BestPriceURL != "" {
|
||||
<a class="text-sm" href={ templ.SafeURL(d.Item.BestPriceURL) } target="_blank" rel="noopener">{ d.Item.BestPriceStore }</a>
|
||||
}
|
||||
} else {
|
||||
<div class="v-muted">no price yet</div>
|
||||
}
|
||||
if d.Badge.Label != "" {
|
||||
<div class="mt-2">
|
||||
<span class={ "v-badge", d.Badge.Class }>{ d.Badge.Label }</span>
|
||||
</div>
|
||||
}
|
||||
<form class="mt-3" hx-post={ fmt.Sprintf("/items/%d/run", d.Item.ID) } hx-swap="none">
|
||||
<input type="hidden" name="csrf_token" value={ d.CSRFToken }/>
|
||||
<button class="v-btn" type="submit">Run Now</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="v-card p-5 mb-6">
|
||||
<h2 class="font-semibold mb-3">Price History</h2>
|
||||
if len(d.History) < 2 {
|
||||
<div class="v-muted">Not enough history yet.</div>
|
||||
} else {
|
||||
<canvas id="price-chart" height="120"></canvas>
|
||||
<script src="/static/vendor/chart.umd.min.js"></script>
|
||||
<script id="price-data" type="application/json">{ d.HistoryChartJSON }</script>
|
||||
<script>
|
||||
(function(){
|
||||
var data = JSON.parse(document.getElementById('price-data').textContent);
|
||||
var ctx = document.getElementById('price-chart').getContext('2d');
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [{
|
||||
label: 'Best price',
|
||||
data: data.points,
|
||||
borderColor: '#00e4a4',
|
||||
backgroundColor: 'rgba(0,228,164,0.15)',
|
||||
pointBackgroundColor: '#e84040',
|
||||
pointRadius: 3,
|
||||
tension: 0.25,
|
||||
fill: true
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
scales: {
|
||||
x: { ticks: { color: '#a8c0f0' }, grid: { color: 'rgba(255,255,255,0.07)' } },
|
||||
y: { ticks: { color: '#a8c0f0' }, grid: { color: 'rgba(255,255,255,0.07)' } }
|
||||
},
|
||||
plugins: { legend: { labels: { color: '#ffffff' } } }
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="v-card p-0 overflow-hidden">
|
||||
<table class="v-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Title</th>
|
||||
<th>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/items/%d/results?order=%s", d.Item.ID, toggleOrder(d.Order, "price"))) }>Price</a>
|
||||
</th>
|
||||
<th>Store</th>
|
||||
<th>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/items/%d/results?order=%s", d.Item.ID, toggleOrder(d.Order, "found"))) }>Found</a>
|
||||
</th>
|
||||
<th>Alert</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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>
|
||||
if r.URL != "" {
|
||||
<a href={ templ.SafeURL(r.URL) } target="_blank" rel="noopener">{ r.Title }</a>
|
||||
} else {
|
||||
{ r.Title }
|
||||
}
|
||||
if r.MatchedQuery != "" {
|
||||
<div class="v-muted text-xs">via "{ r.MatchedQuery }"</div>
|
||||
}
|
||||
</td>
|
||||
<td class={ "font-mono", priceClass(r.Price, d.Item.TargetPrice) }>{ fmtPrice(r.Price, r.Currency) }</td>
|
||||
<td class="v-muted">{ r.Source }</td>
|
||||
<td class="v-muted text-sm">{ humanTime(r.FoundAt) }</td>
|
||||
<td>
|
||||
if r.Alerted {
|
||||
<span class="v-pill v-pill-active">sent</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
if d.TotalPages > 1 {
|
||||
<div class="flex gap-2 justify-center my-4">
|
||||
for i := 1; i <= d.TotalPages; i++ {
|
||||
<a class={ pageClass(i, d.Page_) } href={ templ.SafeURL(fmt.Sprintf("/items/%d/results?page=%d&order=%s", d.Item.ID, i, d.Order)) }>{ fmt.Sprintf("%d", i) }</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
func pageClass(i, current int) string {
|
||||
if i == current {
|
||||
return "v-btn"
|
||||
}
|
||||
return "v-btn-ghost"
|
||||
}
|
||||
|
||||
func toggleOrder(current, axis string) string {
|
||||
switch axis {
|
||||
case "price":
|
||||
if current == "price_asc" {
|
||||
return "price_desc"
|
||||
}
|
||||
return "price_asc"
|
||||
case "found":
|
||||
if current == "found_desc" || current == "" {
|
||||
return "found_asc"
|
||||
}
|
||||
return "found_desc"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
templ ItemResults(d ItemResultsData) {
|
||||
@Layout(d.Page, itemResultsBody(d))
|
||||
}
|
||||
|
||||
templ globalResultsBody(d GlobalResultsData) {
|
||||
<div>
|
||||
<h1 class="text-3xl font-semibold mb-6">All Results</h1>
|
||||
<form method="get" action="/results" class="flex items-end gap-3 mb-4">
|
||||
<div>
|
||||
<label class="v-label">Item</label>
|
||||
<select class="v-select" name="item_id">
|
||||
<option value="0">All</option>
|
||||
for _, it := range d.Items {
|
||||
<option value={ fmt.Sprintf("%d", it.ID) } selected?={ d.ItemID == it.ID }>{ it.Name }</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="v-label">From</label>
|
||||
<input class="v-input" type="date" name="from" value={ d.From }/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="v-label">To</label>
|
||||
<input class="v-input" type="date" name="to" value={ d.To }/>
|
||||
</div>
|
||||
<button class="v-btn" type="submit">Filter</button>
|
||||
</form>
|
||||
<div class="v-card p-0 overflow-hidden">
|
||||
<table class="v-table">
|
||||
<thead>
|
||||
<tr><th>Item</th><th>Title</th><th>Price</th><th>Store</th><th>Found</th><th>Alert</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
for _, r := range d.Results {
|
||||
<tr>
|
||||
<td><a href={ templ.SafeURL(fmt.Sprintf("/items/%d/results", r.ItemID)) }>{ r.ItemName }</a></td>
|
||||
<td>
|
||||
if r.URL != "" {
|
||||
<a href={ templ.SafeURL(r.URL) } target="_blank" rel="noopener">{ r.Title }</a>
|
||||
} else {
|
||||
{ r.Title }
|
||||
}
|
||||
if r.MatchedQuery != "" {
|
||||
<div class="v-muted text-xs">via "{ r.MatchedQuery }"</div>
|
||||
}
|
||||
</td>
|
||||
<td class="font-mono">{ fmtPrice(r.Price, r.Currency) }</td>
|
||||
<td class="v-muted">{ r.Source }</td>
|
||||
<td class="v-muted text-sm">{ humanTime(r.FoundAt) }</td>
|
||||
<td>
|
||||
if r.Alerted {
|
||||
<span class="v-pill v-pill-active">sent</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ GlobalResults(d GlobalResultsData) {
|
||||
@Layout(d.Page, globalResultsBody(d))
|
||||
}
|
||||
|
||||
// ChartJSON helper for handlers.
|
||||
type ChartJSON struct {
|
||||
Labels []string `json:"labels"`
|
||||
Points []float64 `json:"points"`
|
||||
}
|
||||
|
||||
func MustChartJSON(c ChartJSON) string {
|
||||
b, _ := json.Marshal(c)
|
||||
return string(b)
|
||||
}
|
||||
866
templates/results_templ.go
Normal file
866
templates/results_templ.go
Normal file
@@ -0,0 +1,866 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"veola/internal/models"
|
||||
)
|
||||
|
||||
type ItemResultsData struct {
|
||||
Page
|
||||
Item models.Item
|
||||
Badge BadgeData
|
||||
History []models.PricePoint
|
||||
Results []models.Result
|
||||
Page_ int
|
||||
TotalPages int
|
||||
Order string
|
||||
HistoryChartJSON string
|
||||
}
|
||||
|
||||
type BadgeData struct {
|
||||
Label string
|
||||
Class string // v-badge-low / v-badge-avg / v-badge-target / ""
|
||||
}
|
||||
|
||||
type GlobalResultsData struct {
|
||||
Page
|
||||
Items []models.Item
|
||||
Results []ItemResultRow
|
||||
ItemID int64
|
||||
From string
|
||||
To string
|
||||
}
|
||||
|
||||
type ItemResultRow struct {
|
||||
models.Result
|
||||
ItemName string
|
||||
}
|
||||
|
||||
func itemResultsBody(d ItemResultsData) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_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_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div><div class=\"flex items-start justify-between mb-4\"><div><h1 class=\"text-3xl font-semibold\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 45, Col: 52}
|
||||
}
|
||||
_, 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, "</h1>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.Item.Category != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"v-muted\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.Category)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 47, Col: 43}
|
||||
}
|
||||
_, 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, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div><div class=\"text-right\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.Item.BestPrice != nil {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"font-mono text-3xl\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(d.Item.BestPrice, "USD"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 52, Col: 72}
|
||||
}
|
||||
_, 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, 7, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.Item.BestPriceURL != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<a class=\"text-sm\" href=\"")
|
||||
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(d.Item.BestPriceURL))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 54, Col: 66}
|
||||
}
|
||||
_, 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, 9, "\" target=\"_blank\" rel=\"noopener\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.BestPriceStore)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 54, Col: 123}
|
||||
}
|
||||
_, 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, 10, "</a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"v-muted\">no price yet</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if d.Badge.Label != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"mt-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 = []any{"v-badge", d.Badge.Class}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<span class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var7).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_Var8)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(d.Badge.Label)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 61, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</span></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<form class=\"mt-3\" hx-post=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/items/%d/run", d.Item.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 64, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" hx-swap=\"none\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 65, Col: 63}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\"> <button class=\"v-btn\" type=\"submit\">Run Now</button></form></div></div><div class=\"v-card p-5 mb-6\"><h2 class=\"font-semibold mb-3\">Price History</h2>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(d.History) < 2 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"v-muted\">Not enough history yet.</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<canvas id=\"price-chart\" height=\"120\"></canvas><script src=\"/static/vendor/chart.umd.min.js\"></script> <script id=\"price-data\" type=\"application/json\">{ d.HistoryChartJSON }</script> <script>\n\t\t\t\t(function(){\n\t\t\t\t\tvar data = JSON.parse(document.getElementById('price-data').textContent);\n\t\t\t\t\tvar ctx = document.getElementById('price-chart').getContext('2d');\n\t\t\t\t\tnew Chart(ctx, {\n\t\t\t\t\t\ttype: 'line',\n\t\t\t\t\t\tdata: {\n\t\t\t\t\t\t\tlabels: data.labels,\n\t\t\t\t\t\t\tdatasets: [{\n\t\t\t\t\t\t\t\tlabel: 'Best price',\n\t\t\t\t\t\t\t\tdata: data.points,\n\t\t\t\t\t\t\t\tborderColor: '#00e4a4',\n\t\t\t\t\t\t\t\tbackgroundColor: 'rgba(0,228,164,0.15)',\n\t\t\t\t\t\t\t\tpointBackgroundColor: '#e84040',\n\t\t\t\t\t\t\t\tpointRadius: 3,\n\t\t\t\t\t\t\t\ttension: 0.25,\n\t\t\t\t\t\t\t\tfill: true\n\t\t\t\t\t\t\t}]\n\t\t\t\t\t\t},\n\t\t\t\t\t\toptions: {\n\t\t\t\t\t\t\tscales: {\n\t\t\t\t\t\t\t\tx: { ticks: { color: '#a8c0f0' }, grid: { color: 'rgba(255,255,255,0.07)' } },\n\t\t\t\t\t\t\t\ty: { ticks: { color: '#a8c0f0' }, grid: { color: 'rgba(255,255,255,0.07)' } }\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tplugins: { legend: { labels: { color: '#ffffff' } } }\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\t\t\t\t})();\n\t\t\t\t</script>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div><div class=\"v-card p-0 overflow-hidden\"><table class=\"v-table\"><thead><tr><th></th><th>Title</th><th><a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 templ.SafeURL
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results?order=%s", d.Item.ID, toggleOrder(d.Order, "price"))))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 118, Col: 115}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\">Price</a></th><th>Store</th><th><a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 templ.SafeURL
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results?order=%s", d.Item.ID, toggleOrder(d.Order, "found"))))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 122, Col: 115}
|
||||
}
|
||||
_, 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, 23, "\">Found</a></th><th>Alert</th></tr></thead> <tbody>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, r := range d.Results {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<tr><td>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if r.ImageURL != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<img src=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ImageURL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 132, Col: 30}
|
||||
}
|
||||
_, 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, 26, "\" alt=\"\" class=\"w-10 h-10 object-cover rounded\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</td><td>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if r.URL != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 templ.SafeURL
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.URL))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 137, Col: 39}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" target=\"_blank\" rel=\"noopener\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 137, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 139, Col: 18}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if r.MatchedQuery != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<div class=\"v-muted text-xs\">via \"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(r.MatchedQuery)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 142, Col: 59}
|
||||
}
|
||||
_, 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, "\"</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</td>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 = []any{"font-mono", priceClass(r.Price, d.Item.TargetPrice)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var19...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<td class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var19).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(r.Price, r.Currency))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 145, Col: 105}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</td><td class=\"v-muted\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(r.Source)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 146, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</td><td class=\"v-muted text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(r.FoundAt))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 147, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</td><td>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if r.Alerted {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<span class=\"v-pill v-pill-active\">sent</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</td></tr>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</tbody></table></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.TotalPages > 1 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<div class=\"flex gap-2 justify-center my-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for i := 1; i <= d.TotalPages; i++ {
|
||||
var templ_7745c5c3_Var24 = []any{pageClass(i, d.Page_)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var24...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<a class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var24).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var26 templ.SafeURL
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results?page=%d&order=%s", d.Item.ID, i, d.Order)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 161, Col: 134}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var27 string
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", i))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 161, Col: 159}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func pageClass(i, current int) string {
|
||||
if i == current {
|
||||
return "v-btn"
|
||||
}
|
||||
return "v-btn-ghost"
|
||||
}
|
||||
|
||||
func toggleOrder(current, axis string) string {
|
||||
switch axis {
|
||||
case "price":
|
||||
if current == "price_asc" {
|
||||
return "price_desc"
|
||||
}
|
||||
return "price_asc"
|
||||
case "found":
|
||||
if current == "found_desc" || current == "" {
|
||||
return "found_asc"
|
||||
}
|
||||
return "found_desc"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func ItemResults(d ItemResultsData) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_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_Var28 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var28 == nil {
|
||||
templ_7745c5c3_Var28 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = Layout(d.Page, itemResultsBody(d)).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func globalResultsBody(d GlobalResultsData) 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, 50, "<div><h1 class=\"text-3xl font-semibold mb-6\">All Results</h1><form method=\"get\" action=\"/results\" class=\"flex items-end gap-3 mb-4\"><div><label class=\"v-label\">Item</label> <select class=\"v-select\" name=\"item_id\"><option value=\"0\">All</option> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, it := range d.Items {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", it.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 204, Col: 46}
|
||||
}
|
||||
_, 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, 52, "\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.ItemID == it.ID {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, ">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(it.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 204, Col: 90}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</select></div><div><label class=\"v-label\">From</label> <input class=\"v-input\" type=\"date\" name=\"from\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var32 string
|
||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.From)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 210, Col: 65}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\"></div><div><label class=\"v-label\">To</label> <input class=\"v-input\" type=\"date\" name=\"to\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var33 string
|
||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.To)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 214, Col: 61}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\"></div><button class=\"v-btn\" type=\"submit\">Filter</button></form><div class=\"v-card p-0 overflow-hidden\"><table class=\"v-table\"><thead><tr><th>Item</th><th>Title</th><th>Price</th><th>Store</th><th>Found</th><th>Alert</th></tr></thead> <tbody>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, r := range d.Results {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<tr><td><a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var34 templ.SafeURL
|
||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results", r.ItemID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 226, Col: 78}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var35 string
|
||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(r.ItemName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 226, Col: 93}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</a></td><td>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if r.URL != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var36 templ.SafeURL
|
||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.URL))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 229, Col: 39}
|
||||
}
|
||||
_, 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, 63, "\" target=\"_blank\" rel=\"noopener\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var37 string
|
||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 229, Col: 82}
|
||||
}
|
||||
_, 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, 64, "</a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
var templ_7745c5c3_Var38 string
|
||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 231, Col: 18}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if r.MatchedQuery != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<div class=\"v-muted text-xs\">via \"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var39 string
|
||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(r.MatchedQuery)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 234, Col: 59}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\"</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</td><td class=\"font-mono\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var40 string
|
||||
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(r.Price, r.Currency))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 237, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</td><td class=\"v-muted\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var41 string
|
||||
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(r.Source)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 238, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</td><td class=\"v-muted text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var42 string
|
||||
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(r.FoundAt))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 239, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</td><td>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if r.Alerted {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<span class=\"v-pill v-pill-active\">sent</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</td></tr>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</tbody></table></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func GlobalResults(d GlobalResultsData) 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_Var43 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var43 == nil {
|
||||
templ_7745c5c3_Var43 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = Layout(d.Page, globalResultsBody(d)).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ChartJSON helper for handlers.
|
||||
type ChartJSON struct {
|
||||
Labels []string `json:"labels"`
|
||||
Points []float64 `json:"points"`
|
||||
}
|
||||
|
||||
func MustChartJSON(c ChartJSON) string {
|
||||
b, _ := json.Marshal(c)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
156
templates/settings.templ
Normal file
156
templates/settings.templ
Normal file
@@ -0,0 +1,156 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"veola/internal/models"
|
||||
)
|
||||
|
||||
type SettingsData struct {
|
||||
Page
|
||||
Values map[string]string
|
||||
IsAdmin bool
|
||||
Users []models.User
|
||||
TestNtfyOK string
|
||||
TestApifyOK string
|
||||
PasswordMsg string
|
||||
PasswordError string
|
||||
UserMsg string
|
||||
UserError string
|
||||
}
|
||||
|
||||
templ settingsBody(d SettingsData) {
|
||||
<div class="space-y-8 max-w-3xl">
|
||||
<h1 class="text-3xl font-semibold">Settings</h1>
|
||||
|
||||
<section class="v-card p-6">
|
||||
<h2 class="font-semibold mb-4">Apify and Ntfy</h2>
|
||||
<form method="post" action="/settings" class="space-y-4">
|
||||
@CSRFInput(d.CSRFToken)
|
||||
<div>
|
||||
<label class="v-label">Apify API Key</label>
|
||||
<input class="v-input font-mono" type="password" name="apify_api_key" value={ d.Values["apify_api_key"] }/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="v-label">Ntfy Base URL</label>
|
||||
<input class="v-input" name="ntfy_base_url" value={ d.Values["ntfy_base_url"] }/>
|
||||
</div>
|
||||
<div>
|
||||
<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" value={ d.Values["ntfy_token"] } placeholder="tk_... (leave blank if ntfy is unauthenticated)"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="v-label">Global Poll Interval (minutes)</label>
|
||||
<input class="v-input font-mono" name="global_poll_interval_minutes" type="number" value={ d.Values["global_poll_interval_minutes"] }/>
|
||||
</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={ d.Values["match_confidence_threshold"] }/>
|
||||
</div>
|
||||
if !d.IsAdmin {
|
||||
<div class="v-muted text-sm">Read-only for non-admin users.</div>
|
||||
} else {
|
||||
<div class="flex items-center gap-3 pt-1">
|
||||
<button class="v-btn" type="submit">Save</button>
|
||||
<button class="v-btn-ghost" type="submit" formaction="/settings/test-ntfy">Test Ntfy</button>
|
||||
<button class="v-btn-ghost" type="submit" formaction="/settings/test-apify">Test Apify</button>
|
||||
</div>
|
||||
}
|
||||
</form>
|
||||
if d.TestNtfyOK != "" {
|
||||
<div class="v-flash mt-3">{ d.TestNtfyOK }</div>
|
||||
}
|
||||
if d.TestApifyOK != "" {
|
||||
<div class="v-flash mt-3">{ d.TestApifyOK }</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="v-card p-6">
|
||||
<h2 class="font-semibold mb-4">Change Password</h2>
|
||||
if d.PasswordError != "" {
|
||||
<div class="v-flash-error">{ d.PasswordError }</div>
|
||||
}
|
||||
if d.PasswordMsg != "" {
|
||||
<div class="v-flash">{ d.PasswordMsg }</div>
|
||||
}
|
||||
<form method="post" action="/settings/password" class="space-y-4">
|
||||
@CSRFInput(d.CSRFToken)
|
||||
<div>
|
||||
<label class="v-label">Current Password</label>
|
||||
<input class="v-input" type="password" name="current_password"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="v-label">New Password</label>
|
||||
<input class="v-input" type="password" name="new_password"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="v-label">Confirm New Password</label>
|
||||
<input class="v-input" type="password" name="new_password_confirm"/>
|
||||
</div>
|
||||
<button class="v-btn" type="submit">Update Password</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
if d.IsAdmin {
|
||||
<section class="v-card p-6">
|
||||
<h2 class="font-semibold mb-4">Users</h2>
|
||||
if d.UserError != "" {
|
||||
<div class="v-flash-error">{ d.UserError }</div>
|
||||
}
|
||||
if d.UserMsg != "" {
|
||||
<div class="v-flash">{ d.UserMsg }</div>
|
||||
}
|
||||
<table class="v-table mb-4">
|
||||
<thead><tr><th>Username</th><th>Role</th><th>Created</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
for _, u := range d.Users {
|
||||
<tr>
|
||||
<td>{ u.Username }</td>
|
||||
<td class="v-muted">{ string(u.Role) }</td>
|
||||
<td class="v-muted text-sm">{ u.CreatedAt.Format("2006-01-02") }</td>
|
||||
<td class="text-right">
|
||||
<form class="inline" method="post" action={ templ.SafeURL(fmt.Sprintf("/users/%d/reset-password", u.ID)) }>
|
||||
<input type="hidden" name="csrf_token" value={ d.CSRFToken }/>
|
||||
<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.SafeURL(fmt.Sprintf("/users/%d/delete", u.ID)) } onsubmit="return confirm('Remove user?')">
|
||||
<input type="hidden" name="csrf_token" value={ d.CSRFToken }/>
|
||||
<button class="v-btn-ghost" type="submit">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<form method="post" action="/users" class="grid md:grid-cols-4 gap-3 items-end">
|
||||
<input type="hidden" name="csrf_token" value={ d.CSRFToken }/>
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ Settings(d SettingsData) {
|
||||
@Layout(d.Page, settingsBody(d))
|
||||
}
|
||||
447
templates/settings_templ.go
Normal file
447
templates/settings_templ.go
Normal file
@@ -0,0 +1,447 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"veola/internal/models"
|
||||
)
|
||||
|
||||
type SettingsData struct {
|
||||
Page
|
||||
Values map[string]string
|
||||
IsAdmin bool
|
||||
Users []models.User
|
||||
TestNtfyOK string
|
||||
TestApifyOK string
|
||||
PasswordMsg string
|
||||
PasswordError string
|
||||
UserMsg string
|
||||
UserError string
|
||||
}
|
||||
|
||||
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_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"space-y-8 max-w-3xl\"><h1 class=\"text-3xl font-semibold\">Settings</h1><section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Apify and Ntfy</h2><form method=\"post\" action=\"/settings\" class=\"space-y-4\">")
|
||||
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, 2, "<div><label class=\"v-label\">Apify API Key</label> <input class=\"v-input font-mono\" type=\"password\" name=\"apify_api_key\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["apify_api_key"])
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 32, Col: 108}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"></div><div><label class=\"v-label\">Ntfy Base URL</label> <input class=\"v-input\" name=\"ntfy_base_url\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_base_url"])
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 36, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\"></div><div><label class=\"v-label\">Ntfy Default Topic</label> <input class=\"v-input\" name=\"ntfy_default_topic\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_default_topic"])
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 40, Col: 92}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\"></div><div><label class=\"v-label\">Ntfy Token</label> <input class=\"v-input font-mono\" type=\"password\" name=\"ntfy_token\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_token"])
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 44, Col: 102}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" placeholder=\"tk_... (leave blank if ntfy is unauthenticated)\"></div><div><label class=\"v-label\">Global Poll Interval (minutes)</label> <input class=\"v-input font-mono\" name=\"global_poll_interval_minutes\" type=\"number\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["global_poll_interval_minutes"])
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 48, Col: 136}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\"></div><div><label class=\"v-label\">Match Confidence Threshold</label> <input class=\"v-input font-mono\" name=\"match_confidence_threshold\" type=\"number\" min=\"0\" max=\"1\" step=\"0.05\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["match_confidence_threshold"])
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 52, Col: 160}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !d.IsAdmin {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"v-muted text-sm\">Read-only for non-admin users.</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"flex items-center gap-3 pt-1\"><button class=\"v-btn\" type=\"submit\">Save</button> <button class=\"v-btn-ghost\" type=\"submit\" formaction=\"/settings/test-ntfy\">Test Ntfy</button> <button class=\"v-btn-ghost\" type=\"submit\" formaction=\"/settings/test-apify\">Test Apify</button></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.TestNtfyOK != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"v-flash mt-3\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestNtfyOK)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 65, Col: 44}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if d.TestApifyOK != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"v-flash mt-3\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestApifyOK)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 68, Col: 45}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</section><section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Change Password</h2>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.PasswordError != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"v-flash-error\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordError)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 75, Col: 48}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if d.PasswordMsg != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"v-flash\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordMsg)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 78, Col: 40}
|
||||
}
|
||||
_, 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
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<form method=\"post\" action=\"/settings/password\" 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, 22, "<div><label class=\"v-label\">Current Password</label> <input class=\"v-input\" type=\"password\" name=\"current_password\"></div><div><label class=\"v-label\">New Password</label> <input class=\"v-input\" type=\"password\" name=\"new_password\"></div><div><label class=\"v-label\">Confirm New Password</label> <input class=\"v-input\" type=\"password\" name=\"new_password_confirm\"></div><button class=\"v-btn\" type=\"submit\">Update Password</button></form></section>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.IsAdmin {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Users</h2>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.UserError != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"v-flash-error\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserError)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 102, Col: 45}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if d.UserMsg != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<div class=\"v-flash\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserMsg)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 105, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<table class=\"v-table mb-4\"><thead><tr><th>Username</th><th>Role</th><th>Created</th><th></th></tr></thead> <tbody>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, u := range d.Users {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<tr><td>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 112, Col: 24}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</td><td class=\"v-muted\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(string(u.Role))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 113, Col: 44}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</td><td class=\"v-muted text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(u.CreatedAt.Format("2006-01-02"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 114, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</td><td class=\"text-right\"><form class=\"inline\" method=\"post\" action=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 templ.SafeURL
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/users/%d/reset-password", u.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 116, Col: 113}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 117, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\"> <input type=\"password\" class=\"v-input inline-block max-w-[140px]\" name=\"new_password\" placeholder=\"new password\"> <button class=\"v-btn-ghost\" type=\"submit\">Reset</button></form><form class=\"inline\" method=\"post\" action=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 templ.SafeURL
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/users/%d/delete", u.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 121, Col: 105}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" onsubmit=\"return confirm('Remove user?')\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 122, Col: 68}
|
||||
}
|
||||
_, 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, 36, "\"> <button class=\"v-btn-ghost\" type=\"submit\">Remove</button></form></td></tr>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</tbody></table><form method=\"post\" action=\"/users\" class=\"grid md:grid-cols-4 gap-3 items-end\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 131, Col: 63}
|
||||
}
|
||||
_, 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, 38, "\"><div><label class=\"v-label\">Username</label> <input class=\"v-input\" name=\"username\"></div><div><label class=\"v-label\">Role</label> <select class=\"v-select\" name=\"role\"><option value=\"user\">user</option> <option value=\"admin\">admin</option></select></div><div><label class=\"v-label\">Initial Password</label> <input class=\"v-input\" type=\"password\" name=\"password\"></div><button class=\"v-btn\" type=\"submit\">Add User</button></form></section>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
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_Var22 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var22 == nil {
|
||||
templ_7745c5c3_Var22 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = Layout(d.Page, settingsBody(d)).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
786
veola-spec.md
Normal file
786
veola-spec.md
Normal file
@@ -0,0 +1,786 @@
|
||||
# Veola — Claude Code Reference Specification
|
||||
|
||||
> DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
|
||||
|
||||
This document is the authoritative specification for a self-hosted Go web application that tracks items across e-commerce platforms and delivers deal alerts via push notification. It is written for Claude Code and should be followed precisely.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
A Go web application named Veola for tracking specific items across multiple e-commerce platforms (eBay, Amazon, etc.) via the Apify API. The operator adds items to watch via a web interface, configures per-item alert thresholds, and receives push notifications via a self-hosted Ntfy instance when deals are found. All configuration, results, and history are managed through the web UI.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Choice | Notes |
|
||||
|---|---|---|
|
||||
| Language | Go 1.22+ | Standard library preferred where possible |
|
||||
| Templates | [a-h/templ](https://github.com/a-h/templ) | Type-safe HTML components |
|
||||
| Reactivity | [HTMX](https://htmx.org) | Via CDN, no build step |
|
||||
| CSS | [Tailwind CSS](https://tailwindcss.com) | Via CDN Play CDN for development; document where to swap for production build |
|
||||
| Database | SQLite | Use `modernc.org/sqlite` (pure Go, no CGO required) |
|
||||
| HTTP router | `net/http` + `chi` | `github.com/go-chi/chi/v5` |
|
||||
| Config | TOML config file | Use `github.com/BurntSushi/toml` |
|
||||
| Scheduler | `robfig/cron` | `github.com/robfig/cron/v3` |
|
||||
|
||||
---
|
||||
|
||||
## Visual Design Direction
|
||||
|
||||
The app is named Veola, after the magic shop owner from Atelier Iris: Eternal Mana. Taciturn, precise, and better at the job than anyone else in the room. The aesthetic should reflect that: no-nonsense, visually distinctive, purpose-built.
|
||||
|
||||
**Direction: Sega blue**
|
||||
|
||||
The reference point is the Sega Genesis/Mega Drive hardware era — the distinctive saturated blue of the console body, the bright Sega logo blue, and the high-contrast white text of that period's UI. Not dark mode, not light mode — blue mode.
|
||||
|
||||
Color palette:
|
||||
- Background: `#1a2b6d` (Genesis console blue — deep, saturated, not navy)
|
||||
- Surface/cards: `#1f3380` (slightly lighter, enough contrast to lift cards off the background)
|
||||
- Primary accent: `#00a4e4` (Sega logo blue — bright, electric)
|
||||
- Secondary accent: `#f5c400` (Sega arcade button yellow — used very sparingly for price highlights and active states)
|
||||
- Text primary: `#ffffff`
|
||||
- Text secondary: `#a8c0f0` (desaturated blue-white for secondary labels and timestamps)
|
||||
- Danger/error: `#e84040` (bright red — high contrast against blue)
|
||||
- Success/below-target price: `#00e4a4` (mint, complementary to the Sega blue)
|
||||
- Border: `rgba(255, 255, 255, 0.12)` (subtle white rule)
|
||||
|
||||
Typography:
|
||||
- Monospaced font for prices and data values: `JetBrains Mono` or `Fira Code` via Google Fonts
|
||||
- UI chrome: `Outfit` or `DM Sans` — clean, slightly geometric, fits the era
|
||||
- Do not use Inter, Roboto, or system fonts
|
||||
|
||||
Surface treatment:
|
||||
- Cards: solid `#1f3380` background, `1px` border at `rgba(255,255,255,0.12)`, `border-radius: 8px`
|
||||
- No glassmorphism, no blur effects, no texture — solid surfaces only
|
||||
- No texture effects — clean flat surfaces throughout
|
||||
- Box shadows use blue tones, not black: `0 4px 16px rgba(0, 0, 80, 0.4)`
|
||||
|
||||
Component specifics:
|
||||
- Status pills: Active (`#00a4e4` background, white text), Paused (white/10 background, `#a8c0f0` text), Error (`#e84040` background, white text)
|
||||
- Prices: `JetBrains Mono`, large, `#f5c400` when at or below target, `#ffffff` otherwise
|
||||
- Primary buttons: `#00a4e4` background, white text, no rounded pill — `border-radius: 6px`
|
||||
- Navigation active state: `#00a4e4` left border accent, slightly lighter surface
|
||||
|
||||
Do not introduce purple. Do not use gradients on backgrounds. Do not produce a generic dark SaaS dashboard with blue accents — the background itself is blue, which is the whole point.
|
||||
|
||||
---
|
||||
|
||||
## Application Structure
|
||||
|
||||
```
|
||||
veola/
|
||||
├── main.go
|
||||
├── config.toml.example
|
||||
├── internal/
|
||||
│ ├── config/
|
||||
│ │ └── config.go # TOML config loading and validation
|
||||
│ ├── db/
|
||||
│ │ ├── db.go # SQLite init, migrations
|
||||
│ │ └── queries.go # All DB operations
|
||||
│ ├── models/
|
||||
│ │ └── models.go # Item, Result, PricePoint, Setting structs
|
||||
│ ├── apify/
|
||||
│ │ └── client.go # Apify API client
|
||||
│ ├── ntfy/
|
||||
│ │ └── client.go # Ntfy push client
|
||||
│ ├── auth/
|
||||
│ │ └── auth.go # Session management, bcrypt, middleware
|
||||
│ ├── crypto/
|
||||
│ │ └── crypto.go # AES-256-GCM encrypt/decrypt, key derivation
|
||||
│ ├── scheduler/
|
||||
│ │ └── scheduler.go # Cron-based poll scheduler
|
||||
│ └── handlers/
|
||||
│ ├── items.go # Item CRUD + preview handlers
|
||||
│ ├── results.go # Results view handlers
|
||||
│ ├── settings.go # Settings handlers
|
||||
│ ├── auth.go # Login/logout handlers
|
||||
│ └── dashboard.go # Dashboard handler
|
||||
├── templates/
|
||||
│ ├── layout.templ # Base layout, nav
|
||||
│ ├── login.templ # Login page
|
||||
│ ├── dashboard.templ
|
||||
│ ├── items.templ
|
||||
│ ├── item_form.templ
|
||||
│ ├── item_preview.templ # Preview results before confirming add
|
||||
│ ├── results.templ
|
||||
│ └── settings.templ
|
||||
└── static/
|
||||
└── (any local static assets if needed)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
All migrations run at startup via embedded SQL. Use `modernc.org/sqlite` with WAL mode enabled.
|
||||
|
||||
```sql
|
||||
PRAGMA journal_mode=WAL;
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL, -- bcrypt hash, cost factor 12
|
||||
role TEXT NOT NULL DEFAULT 'user', -- 'admin' or 'user'
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
search_query TEXT, -- free text search (optional)
|
||||
url TEXT, -- specific product URL (optional)
|
||||
category TEXT, -- user-defined label
|
||||
target_price REAL, -- alert threshold (NULL = alert on any result)
|
||||
ntfy_topic TEXT NOT NULL, -- per-item ntfy topic slug
|
||||
ntfy_priority TEXT DEFAULT 'default', -- min, low, default, high, urgent
|
||||
poll_interval_minutes INTEGER DEFAULT 60,
|
||||
include_out_of_stock INTEGER DEFAULT 0,
|
||||
active INTEGER DEFAULT 1, -- 0 = paused
|
||||
last_polled_at DATETIME, -- timestamp of last completed poll
|
||||
last_poll_error TEXT, -- most recent error message, NULL if last poll succeeded
|
||||
best_price REAL, -- lowest price seen in most recent poll
|
||||
best_price_store TEXT, -- store name for best_price
|
||||
best_price_url TEXT, -- direct listing URL for best_price
|
||||
best_price_image_url TEXT, -- product image URL from best result
|
||||
best_price_title TEXT, -- product title from best result
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS results (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
title TEXT,
|
||||
price REAL,
|
||||
currency TEXT NOT NULL, -- ISO 4217 code: USD, JPY, GBP, EUR, etc. Never assume USD.
|
||||
url TEXT,
|
||||
source TEXT, -- e.g. "ebay", "amazon"
|
||||
image_url TEXT,
|
||||
alerted INTEGER DEFAULT 0, -- 1 = ntfy notification sent
|
||||
found_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- price_history records the best price observed per item per poll cycle.
|
||||
-- Used to render the price history chart. One row per poll run.
|
||||
CREATE TABLE IF NOT EXISTS price_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
price REAL NOT NULL, -- best (lowest) price seen in this poll
|
||||
store TEXT,
|
||||
polled_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Seed default settings keys (values populated via UI or env)
|
||||
INSERT OR IGNORE INTO settings (key, value) VALUES
|
||||
('apify_api_key', ''),
|
||||
('ntfy_base_url', ''),
|
||||
('ntfy_default_topic', 'veola'),
|
||||
('global_poll_interval_minutes', '60'),
|
||||
('match_confidence_threshold', '0.6');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Config is loaded from a TOML file. The path defaults to `./config.toml` and can be overridden with the `-config` flag at startup:
|
||||
|
||||
```
|
||||
./veola -config /etc/veola/config.toml
|
||||
```
|
||||
|
||||
The config file is the single source of truth. There are no environment variable overrides and no DB-stored settings that duplicate what is in the file — the Settings UI stores only Apify and Ntfy operational settings in the DB (API keys, topics, thresholds). Server and security settings live in the file only.
|
||||
|
||||
`config.toml.example`:
|
||||
|
||||
```toml
|
||||
[server]
|
||||
port = 8080
|
||||
db_path = "./veola.db"
|
||||
|
||||
[security]
|
||||
session_secret = "change-this-to-a-random-32-byte-string"
|
||||
encryption_key = "change-this-to-a-different-random-32-byte-string"
|
||||
|
||||
[apify]
|
||||
api_key = ""
|
||||
|
||||
[apify.actors]
|
||||
active_listings = "harvestlab/ebay-scraper"
|
||||
sold_listings = "automation-lab/ebay-sold-scraper"
|
||||
price_comparison = "junipr/price-comparison"
|
||||
|
||||
[ntfy]
|
||||
base_url = "https://ntfy.yourdomain.com"
|
||||
default_topic = "veola"
|
||||
|
||||
[scheduler]
|
||||
global_poll_interval_minutes = 60
|
||||
match_confidence_threshold = 0.6
|
||||
```
|
||||
|
||||
The Go config struct mirrors this structure exactly:
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
Server ServerConfig `toml:"server"`
|
||||
Security SecurityConfig `toml:"security"`
|
||||
Apify ApifyConfig `toml:"apify"`
|
||||
Ntfy NtfyConfig `toml:"ntfy"`
|
||||
Scheduler SchedulerConfig `toml:"scheduler"`
|
||||
}
|
||||
```
|
||||
|
||||
Startup validation (exit on failure):
|
||||
- `security.session_secret` must be at least 32 bytes
|
||||
- `security.encryption_key` must be at least 32 bytes
|
||||
- `security.session_secret` and `security.encryption_key` must not be equal
|
||||
- `server.db_path` must be a writable path
|
||||
- Config file must exist and parse without error; missing file is a fatal error with a clear message pointing to `config.toml.example`
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
Single-user setup for v1 but the schema and auth layer support multiple users from the start. All users share the same item list — items are not scoped per user. Each user manages their own ntfy topic subscription independently.
|
||||
|
||||
**User roles:**
|
||||
- `admin` — can manage users (add, remove, reset passwords), access all settings
|
||||
- `user` — can manage items, view results, view settings (read-only)
|
||||
|
||||
The first account created via `/setup` is automatically assigned the `admin` role. Subsequent accounts are created by an admin from the Settings page — no self-registration.
|
||||
|
||||
**Package: `internal/auth/auth.go`**
|
||||
|
||||
- Password hashing: `bcrypt` at cost factor 12 (`golang.org/x/crypto/bcrypt`)
|
||||
- Sessions: signed cookies using `gorilla/sessions` with `SESSION_SECRET` from env
|
||||
- Session lifetime: 7 days, renewed on each request
|
||||
- All routes except `/login` and `/setup` are protected by auth middleware
|
||||
- Middleware checks for a valid session cookie; redirects to `/login` on failure
|
||||
|
||||
**First-run setup:**
|
||||
- On startup, if the `users` table is empty, the app redirects all requests to `/setup`
|
||||
- `/setup` renders a form to create the initial username and password
|
||||
- Password must be at least 12 characters — validate server-side
|
||||
- After setup completes, redirect to `/login`
|
||||
- `/setup` is inaccessible once a user exists (return 404)
|
||||
|
||||
**Login (`/login`):**
|
||||
- Simple form: username + password
|
||||
- On success: create session, redirect to `/`
|
||||
- On failure: show "Invalid username or password" (do not indicate which field was wrong)
|
||||
- No rate limiting required for v1 (single-user, self-hosted)
|
||||
|
||||
**Logout (`/logout`):**
|
||||
- POST only (CSRF-safe)
|
||||
- Clears session, redirects to `/login`
|
||||
|
||||
**Password change:**
|
||||
- Available in Settings page
|
||||
- Requires current password confirmation before accepting new password
|
||||
|
||||
---
|
||||
|
||||
## Data Encryption
|
||||
|
||||
Application-level column encryption using AES-256-GCM. The SQLite file remains unencrypted at the filesystem level; sensitive values are encrypted before write and decrypted after read in the query layer. No CGO required.
|
||||
|
||||
**Package: `internal/crypto/crypto.go`**
|
||||
|
||||
Key derivation:
|
||||
- Raw key material comes from `security.encryption_key` in `config.toml` (must be at least 32 bytes; app exits on startup if absent or too short)
|
||||
- Derive a 32-byte AES key using HKDF-SHA256 (`golang.org/x/crypto/hkdf`) with a fixed info string of `"veola-v1"`
|
||||
- The derived key is held in memory for the lifetime of the process; never written to disk
|
||||
|
||||
Encryption scheme:
|
||||
- Algorithm: AES-256-GCM (`crypto/aes`, `crypto/cipher`)
|
||||
- Nonce: 12 bytes, randomly generated per encryption call (`crypto/rand`)
|
||||
- Storage format: base64(nonce + ciphertext + tag), stored as TEXT in SQLite
|
||||
- Prefix encrypted values with the string `enc:` so the query layer can distinguish encrypted from plaintext values (relevant during any future migration)
|
||||
|
||||
```go
|
||||
// Public interface
|
||||
func Encrypt(key []byte, plaintext string) (string, error)
|
||||
func Decrypt(key []byte, ciphertext string) (string, error)
|
||||
func IsEncrypted(value string) bool // checks for "enc:" prefix
|
||||
```
|
||||
|
||||
**Encrypted fields:**
|
||||
|
||||
| Table | Column |
|
||||
|---|---|
|
||||
| `settings` | `value` (all rows) |
|
||||
| `items` | `search_query`, `url`, `ntfy_topic`, `best_price_url`, `best_price_image_url`, `best_price_title`, `last_poll_error` |
|
||||
| `results` | `title`, `url`, `image_url` |
|
||||
| `price_history` | `store` |
|
||||
| `users` | `username` |
|
||||
|
||||
Fields not encrypted: all numeric values (`price`, `id`, `poll_interval_minutes`, etc.), boolean flags, timestamps, and `currency`. These carry no sensitive information and encrypting them would break sorting and range queries.
|
||||
|
||||
**Query layer integration:**
|
||||
- All encrypt/decrypt calls happen in `internal/db/queries.go` — never in handlers or templates
|
||||
- Structs in `internal/models/models.go` always hold decrypted plaintext values
|
||||
- If decryption fails for a field, log the error and substitute an empty string — do not crash or return an error to the UI for individual field failures
|
||||
- On write: encrypt then store. On read: read then decrypt.
|
||||
|
||||
**Key rotation (document only, do not implement in v1):**
|
||||
- Future: add a `/admin/reencrypt` endpoint that reads all encrypted rows with the old key and rewrites them with a new key. Not in scope for v1.
|
||||
|
||||
**Startup validation** is handled by the config loader (see Configuration section) — the crypto package receives an already-validated key and may assume it is correct length. `SESSION_SECRET` and `ENCRYPTION_KEY` must be different values — validated at config load time.
|
||||
|
||||
---
|
||||
|
||||
## Apify Integration
|
||||
|
||||
**Client: `internal/apify/client.go`**
|
||||
|
||||
Veola uses two distinct actor types per item, serving different purposes. Both share the same underlying API client but with different input schemas and result parsers.
|
||||
|
||||
### API Flow (both actor types)
|
||||
|
||||
1. Start a run via `POST https://api.apify.com/v2/acts/{actorId}/runs`
|
||||
2. Poll run status via `GET https://api.apify.com/v2/actor-runs/{runId}` until `SUCCEEDED` or `FAILED`
|
||||
3. Fetch results via `GET https://api.apify.com/v2/actor-runs/{runId}/dataset/items`
|
||||
4. Return structured results
|
||||
|
||||
All requests authenticated via `?token={apiKey}` query parameter. Run timeout: 5 minutes. API key and all actor IDs loaded from config — never hardcoded.
|
||||
|
||||
### Actor 1 — Active Listings (primary, polling)
|
||||
|
||||
Default actor: `harvestlab/ebay-scraper`
|
||||
|
||||
Used on every scheduled poll cycle. Finds currently available listings — what you can actually buy right now.
|
||||
|
||||
```go
|
||||
type ActiveListingInput struct {
|
||||
Query string `json:"query,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Marketplace string `json:"marketplace,omitempty"` // e.g. "ebay.com", "ebay.co.uk"
|
||||
ListingType string `json:"listingType,omitempty"` // "BIN", "auction", "all"
|
||||
MaxResults int `json:"maxResults,omitempty"`
|
||||
}
|
||||
|
||||
type ActiveListingResult struct {
|
||||
Title string `json:"title"`
|
||||
Price float64 `json:"price"`
|
||||
Currency string `json:"currency"`
|
||||
URL string `json:"url"`
|
||||
Store string `json:"store"`
|
||||
ImageURL string `json:"imageUrl"`
|
||||
Condition string `json:"condition"`
|
||||
ListingType string `json:"listingType"`
|
||||
ShippingPrice float64 `json:"shippingPrice"`
|
||||
FreeShipping bool `json:"freeShipping"`
|
||||
Marketplace string `json:"marketplace"`
|
||||
MatchConfidence float64 `json:"matchConfidence"`
|
||||
}
|
||||
```
|
||||
|
||||
- Default marketplace: `ebay.com` (configurable per item)
|
||||
- Default listing type: `all` (configurable per item)
|
||||
- Minimum `matchConfidence`: 0.6 (configurable in settings)
|
||||
- Filter out out-of-stock results by default (configurable per item)
|
||||
|
||||
### Actor 2 — Sold Listings (historical baseline)
|
||||
|
||||
Default actor: `automation-lab/ebay-sold-scraper`
|
||||
|
||||
Used in two scenarios only:
|
||||
1. On item creation — seeds `price_history` with real completed sale prices before the first active listing poll runs
|
||||
2. Weekly refresh — keeps the historical baseline current with recent sold data
|
||||
|
||||
This ensures deal quality badges ("X% below 30-day average") are based on what things actually sell for, not asking price snapshots.
|
||||
|
||||
```go
|
||||
type SoldListingInput struct {
|
||||
Query string `json:"query"`
|
||||
Marketplace string `json:"marketplace,omitempty"`
|
||||
MaxResults int `json:"maxResults,omitempty"`
|
||||
DaysBack int `json:"daysBack,omitempty"` // default 30
|
||||
}
|
||||
|
||||
type SoldListingResult struct {
|
||||
Title string `json:"title"`
|
||||
SoldPrice float64 `json:"soldPrice"`
|
||||
Currency string `json:"soldCurrency"`
|
||||
SoldAt string `json:"endedAt"`
|
||||
Condition string `json:"condition"`
|
||||
ListingType string `json:"listingType"`
|
||||
ShippingPrice float64 `json:"shippingPrice"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
```
|
||||
|
||||
On item creation, insert one `price_history` row per sold result using `soldPrice` and `endedAt` as the timestamp. This gives deal quality badges real data immediately rather than waiting weeks for polling history to accumulate.
|
||||
|
||||
### Actor 3 — General Price Comparison (secondary, optional)
|
||||
|
||||
Default actor: `junipr/price-comparison`
|
||||
|
||||
Covers Amazon, Walmart, Target, Best Buy, Home Depot. Not eBay. Used as a supplementary source for items where broader retail coverage is wanted alongside eBay. Not enabled by default — configurable per item in the advanced section of the item form.
|
||||
|
||||
```go
|
||||
type PriceComparisonInput struct {
|
||||
Query string `json:"query,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
MatchStrictness string `json:"matchStrictness,omitempty"` // "strict", "normal", "loose"
|
||||
}
|
||||
|
||||
type PriceComparisonResult struct {
|
||||
Title string `json:"title"`
|
||||
Price float64 `json:"price"`
|
||||
Currency string `json:"currency"`
|
||||
URL string `json:"url"`
|
||||
Store string `json:"store"`
|
||||
ImageURL string `json:"imageUrl"`
|
||||
Availability string `json:"availability"`
|
||||
MatchConfidence float64 `json:"matchConfidence"`
|
||||
}
|
||||
```
|
||||
|
||||
### Actor 4 — Yahoo Auctions Japan / JDirectItems (Japanese marketplace)
|
||||
|
||||
Default actor: `styleindexamerica/jp-yahooauctions-scraper`
|
||||
|
||||
Scrapes `auctions.yahoo.co.jp` directly. Known internationally as JDirectItems Auction since January 2025 — the underlying site and URL are unchanged.
|
||||
|
||||
**Important:** Yahoo Auctions Japan is Japanese-language only. Search queries for this marketplace must be in Japanese (kanji/kana) or romaji to return meaningful results. The item form must display a visible warning when this actor is selected: "Yahoo Auctions Japan searches in Japanese. English queries will return few or no results."
|
||||
|
||||
**CJK input requirements:** All text input fields in the item form must handle Japanese IME input correctly. Specifically:
|
||||
- Do not trigger search, validation, or HTMX requests on `keydown` or `keyup` events — use `change` or explicit button actions only. IME composition fires intermediate key events that will break mid-input if the app reacts to them.
|
||||
- Set `lang` attribute appropriately on inputs where Japanese is expected.
|
||||
- Do not truncate, strip, or re-encode non-ASCII characters at any layer — Go, SQLite, and the template layer must all pass CJK strings through unchanged.
|
||||
- Test strings: `ツインビー`, `グラディウス`, `パロディウス` — if these round-trip through the form, DB, and display correctly the implementation is sound.
|
||||
|
||||
A companion sold-price actor is also available for historical baseline seeding: `contented_eyebrow/yafuoku-closedsearch-crawler`
|
||||
|
||||
```go
|
||||
type YahooAuctionsJPInput struct {
|
||||
Query string `json:"query"`
|
||||
MaxItems int `json:"maxItems,omitempty"`
|
||||
}
|
||||
|
||||
type YahooAuctionsJPResult struct {
|
||||
Title string `json:"name"`
|
||||
Price float64 `json:"currentPrice"`
|
||||
Currency string `json:"currency"` // JPY
|
||||
URL string `json:"url"`
|
||||
ImageURL string `json:"imageUrl"`
|
||||
BidsCount int `json:"bidsCount"`
|
||||
TimeLeft string `json:"timeLeft"`
|
||||
EndTime string `json:"endTime"`
|
||||
}
|
||||
```
|
||||
|
||||
Currency will be JPY — the results layer must store and display the original currency rather than assuming USD.
|
||||
|
||||
### Actor 5 — Mercari Japan
|
||||
|
||||
Default actor: community Mercari Japan scraper (verify current best actor on Apify Store before implementation — search "mercari japan" sorted by run count, pick highest with 90%+ success rate and update within 90 days)
|
||||
|
||||
Mercari Japan is a large secondhand marketplace popular for figures, games, and collectibles. Same Japanese-language caveat applies as Yahoo Auctions Japan.
|
||||
|
||||
### Config
|
||||
|
||||
```toml
|
||||
[apify]
|
||||
api_key = ""
|
||||
|
||||
[apify.actors]
|
||||
active_listings = "harvestlab/ebay-scraper"
|
||||
sold_listings = "automation-lab/ebay-sold-scraper"
|
||||
price_comparison = "junipr/price-comparison"
|
||||
yahoo_auctions_jp = "styleindexamerica/jp-yahooauctions-scraper"
|
||||
yahoo_auctions_jp_sold = "contented_eyebrow/yafuoku-closedsearch-crawler"
|
||||
mercari_jp = "" # populate before use — see actor selection note above
|
||||
```
|
||||
|
||||
All three actor IDs are overridable per item in the item form (advanced section, collapsed by default).
|
||||
|
||||
---
|
||||
|
||||
## Ntfy Integration
|
||||
|
||||
**Client: `internal/ntfy/client.go`**
|
||||
|
||||
Post a JSON notification to the self-hosted Ntfy instance.
|
||||
|
||||
```go
|
||||
type Notification struct {
|
||||
Topic string `json:"topic"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Priority string `json:"priority"` // min, low, default, high, urgent
|
||||
Tags []string `json:"tags"`
|
||||
Click string `json:"click,omitempty"` // URL to open on tap
|
||||
}
|
||||
```
|
||||
|
||||
- Base URL is configurable (self-hosted)
|
||||
- Topic is per-item
|
||||
- `Click` should be the product listing URL so tapping the notification opens the deal
|
||||
- Tags: use `["shopping_cart", "tada"]` on a price-threshold hit, `["mag"]` on a general result
|
||||
- Include price and source in the message body
|
||||
- Mark `result.alerted = 1` in DB after successful send
|
||||
|
||||
Example notification for a hit:
|
||||
```
|
||||
Title: Veola Alert: TwinBee Famicom CIB
|
||||
Message: eBay — $42.00 (target: $60.00)
|
||||
Buy It Now · Free Shipping
|
||||
Click: https://ebay.com/itm/...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scheduler
|
||||
|
||||
**`internal/scheduler/scheduler.go`**
|
||||
|
||||
- Use `robfig/cron/v3` with second-level precision disabled (minute granularity is fine)
|
||||
- On startup, load all active items from DB and register a cron job per item based on `poll_interval_minutes`
|
||||
- When an item is created, updated, or toggled active/paused, re-register or remove its cron job without restarting the app
|
||||
- Each job: invoke Apify search, store results in DB, evaluate alert conditions, send Ntfy if triggered
|
||||
- Log each poll cycle with item name, result count, and whether an alert was sent
|
||||
- Jobs run with a context that is cancelled on app shutdown (graceful shutdown required)
|
||||
|
||||
Alert condition logic:
|
||||
```
|
||||
if item.TargetPrice == nil:
|
||||
alert on every new result not previously seen
|
||||
else:
|
||||
alert only when result.Price <= item.TargetPrice
|
||||
```
|
||||
|
||||
Deduplication: a result is "previously seen" if a row exists in `results` with the same `item_id` and `url`. Do not re-alert the same listing.
|
||||
|
||||
---
|
||||
|
||||
## Web UI — Pages and Handlers
|
||||
|
||||
### Layout (`layout.templ`)
|
||||
- Fixed left sidebar navigation on desktop, collapsible on mobile
|
||||
- Nav items: Dashboard, Items, Results, Settings
|
||||
- Header shows app name "Veola" with a small bee emoji or custom SVG icon
|
||||
- Active nav item highlighted with the primary accent color
|
||||
- No top navbar — sidebar only
|
||||
|
||||
### Dashboard (`/`)
|
||||
- Summary cards: Total Items Tracked, Active Items, Results Today, Alerts Sent Today
|
||||
- **Potential Spend card**: sum of `best_price` across all active items with a known best price. Label: "Potential Spend" with a subline showing the item count contributing to the total (e.g. "across 12 items"). Displayed in large monospaced font. Items with no best price yet (never polled or no results) are excluded from the total and noted: "3 items not yet priced."
|
||||
- **Money Saved card**: sum of `(historical_average_price - best_price)` across all active items where both values are known. Historical average is the mean of all `price_history` rows for that item. Only include items where `best_price` is below their historical average — negative savings are not counted. Subline shows contributing item count. Displayed in large monospaced font in the success color (`#00e4a4`).
|
||||
- Recent Results table: last 20 results across all items, showing name, price, source, found time, alert status
|
||||
- Recent Alerts section: last 5 ntfy alerts sent, with item name and price
|
||||
- All data loaded server-side; HTMX auto-refresh every 60 seconds (`hx-trigger="every 60s"`)
|
||||
|
||||
### Items List (`/items`)
|
||||
- Table/card list of all tracked items
|
||||
- Columns: Name, Category, Target Price, Best Price (with store name as a link to the listing), Last Polled, Status (Active/Paused/Error), Actions
|
||||
- Best Price cell: show price in large monospaced font, store name beneath it as a link, highlighted mint green if at or below target price
|
||||
- Last Polled cell: human-readable relative time (e.g. "12 minutes ago"); tooltip shows exact timestamp
|
||||
- Error badge: if `last_poll_error` is set, show a small coral warning badge on the row; clicking it shows the error message inline via HTMX
|
||||
- Actions: Edit, Pause/Resume toggle (HTMX inline toggle, no page reload), Delete (with confirm), Run Now
|
||||
- "Add Item" button opens Step 1 of the item form (HTMX swap into a modal or slide panel)
|
||||
- Filter by category (simple dropdown, no JS required — HTMX get)
|
||||
|
||||
### Add Item Flow (Two-Step)
|
||||
|
||||
The add item flow is a two-step process. The item is not saved to the database until the user confirms after seeing the preview.
|
||||
|
||||
**Step 1 — Entry form (`/items/new`)**
|
||||
|
||||
Fields:
|
||||
- Name (required)
|
||||
- Category (text, optional — HTMX autocomplete from existing categories)
|
||||
- Search Query (textarea, optional)
|
||||
- Product URL (text input, optional)
|
||||
- At least one of Search Query or URL is required — validate server-side before running preview
|
||||
- Target Price (number, optional — blank means alert on any result)
|
||||
- Ntfy Topic (pre-populated as a slug from Name, editable)
|
||||
- Ntfy Priority (select: min / low / default / high / urgent)
|
||||
- Poll Interval (select: 15min / 30min / 1hr / 2hr / 6hr / 12hr / 24hr)
|
||||
- Include Out of Stock (checkbox, default unchecked)
|
||||
|
||||
The primary action button is "Preview" — not "Save". Clicking it submits the form via HTMX POST to `/items/preview`.
|
||||
|
||||
**Step 2 — Preview (`POST /items/preview`)**
|
||||
|
||||
Server validates the form, runs an Apify search immediately, and returns a preview partial (`item_preview.templ`) swapped into the modal/panel. The original form values are preserved as hidden fields in the confirmation form.
|
||||
|
||||
The preview partial displays:
|
||||
- A header: "Found {N} results for '{query}'"
|
||||
- Best result card (largest, top of preview):
|
||||
- Product image (if available) on the left
|
||||
- Title, store name (as a link to the listing), and price on the right
|
||||
- Price in large monospaced font with the currency symbol
|
||||
- "Best Price" label above the price in mint green
|
||||
- Remaining results as a compact list below the best result card:
|
||||
- Each row: image thumbnail, title (truncated), store (linked), price
|
||||
- Show up to 5 additional results
|
||||
- If more than 6 results exist, show "and {N} more" below the list
|
||||
- Price range summary line: "Prices range from $X.XX to $Y.YY across {N} stores"
|
||||
- If no results found: show a warning card "No results found. Try a broader search query." with a Back button only
|
||||
- Two action buttons at the bottom:
|
||||
- "Back" — returns to the entry form with fields repopulated (HTMX swap)
|
||||
- "Confirm and Track" — POST to `/items` to save the item and start tracking
|
||||
|
||||
**Step 2 error states:**
|
||||
- Apify timeout or API error: show error message with a Back button
|
||||
- Zero results after filtering: show warning with Back button
|
||||
|
||||
**`POST /items` (confirm save)**
|
||||
- Validates all fields again server-side
|
||||
- Saves item to DB
|
||||
- Registers cron job in scheduler
|
||||
- Records initial `best_price`, `best_price_store`, `best_price_url`, `best_price_image_url`, `best_price_title` from the preview results (passed as hidden fields or re-fetched — re-fetching is acceptable)
|
||||
- Writes one row to `price_history` for the initial poll
|
||||
- Sets `last_polled_at` to now
|
||||
- Redirects to the new item's results page
|
||||
|
||||
### Edit Item (`/items/{id}/edit`)
|
||||
- Same fields as Step 1 of the add flow
|
||||
- No preview step on edit — changes save directly
|
||||
- Validation errors shown inline via HTMX
|
||||
|
||||
### Results and Price History (`/items/{id}/results`)
|
||||
- Item summary header: name, category, target price, best current price (store + link), last polled timestamp
|
||||
- **Deal Quality badge**: displayed prominently in the header next to the best price. Show the single highest-priority badge that applies, evaluated in this order:
|
||||
1. "All-time low" (mint green, `#00e4a4`) — current `best_price` is the lowest value ever recorded in `price_history` for this item
|
||||
2. "X% below 30-day avg" (Sega blue accent, `#00a4e4`) — current `best_price` is at least 10% below the mean of `price_history` rows from the last 30 days. X is the calculated percentage, rounded to the nearest whole number.
|
||||
3. "X% below target" (yellow, `#f5c400`) — a `target_price` is set and `best_price` is below it. X is the percentage difference.
|
||||
- Show no badge if none of the conditions are met. Do not show multiple badges simultaneously.
|
||||
- Price History chart: line chart rendered via Chart.js (CDN). X axis is poll date/time, Y axis is price. One data point per `price_history` row. Chart is dark-themed to match the app aesthetic (dark background, mint green line, coral point markers). If fewer than 2 data points exist, show "Not enough history yet" instead of the chart.
|
||||
- Results table below the chart:
|
||||
- Columns: Image (thumbnail), Title, Price, Store (linked), Found, Alert Sent
|
||||
- Price highlighted in mint green if at or below target
|
||||
- Sortable by price and found date (server-side, via HTMX get with sort params)
|
||||
- Pagination (20 per page)
|
||||
- "Run Now" button in the header — triggers an immediate Apify poll, updates best price fields, appends to price_history, refreshes the chart and table via HTMX
|
||||
|
||||
### Global Results (`/results`)
|
||||
- All results across all items, most recent first
|
||||
- Columns: Item Name, Title, Price, Store, Found, Alert Sent
|
||||
- Filterable by item and date range
|
||||
|
||||
### Settings (`/settings`)
|
||||
- Form fields for all configurable settings:
|
||||
- Apify API Key (password input, masked)
|
||||
- Ntfy Base URL
|
||||
- Ntfy Default Topic
|
||||
- Global Poll Interval
|
||||
- Match Confidence Threshold (slider or number input, 0.0–1.0)
|
||||
- Note: actor IDs are configured in `config.toml` and in per-item advanced settings, not here
|
||||
- "Test Ntfy" button — sends a test notification to the default topic, result shown inline
|
||||
- "Test Apify" button — runs a sample search ("test query"), shows raw result count inline
|
||||
- Save persists to DB settings table
|
||||
- Password Change section (separate from the above):
|
||||
- Current Password
|
||||
- New Password (min 12 characters)
|
||||
- Confirm New Password
|
||||
- Saved independently from other settings
|
||||
- User Management section (admin only):
|
||||
- List of all users with username, role, and created date
|
||||
- "Add User" form: username, role (admin/user), initial password
|
||||
- "Remove User" button per user (cannot remove yourself)
|
||||
- "Reset Password" button per user — admin sets a new password directly, no current password required
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
- All Apify failures: log error, mark item `last_poll_error` (add TEXT column to items), surface in UI as a warning badge
|
||||
- All Ntfy failures: log error, do not mark result as alerted, retry on next poll cycle
|
||||
- DB errors: log and return 500 with a friendly error page (no stack traces in UI)
|
||||
- Context cancellation on shutdown: in-progress Apify polls should be abandoned cleanly
|
||||
|
||||
---
|
||||
|
||||
## Graceful Shutdown
|
||||
|
||||
- Listen for SIGINT/SIGTERM
|
||||
- Stop cron scheduler (waits for running jobs to complete)
|
||||
- Close DB connection
|
||||
- Log "Veola shutting down"
|
||||
|
||||
---
|
||||
|
||||
## Editorial and Code Style Rules
|
||||
|
||||
- No em dashes anywhere in UI copy or log messages. Use a plain hyphen or restructure the sentence.
|
||||
- No explaining jokes or results to the user. Show the data, let them decide.
|
||||
- Log lines should be structured (key=value pairs), not prose sentences.
|
||||
- Do not add unrequested features. Build exactly what is specified.
|
||||
- Do not rewrite or restructure this document.
|
||||
- Comments in Go code should be terse and factual.
|
||||
- All configurable values (API keys, URLs, actor IDs, model names) must flow through the config struct — never hardcoded.
|
||||
- Templ components should be small and composable. Avoid monolithic template files.
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope (Do Not Implement)
|
||||
|
||||
- Matrix posting (future phase)
|
||||
- Discord integration
|
||||
- Email alerting
|
||||
- Mobile app
|
||||
|
||||
---
|
||||
|
||||
## Suggested Implementation Order for Claude Code
|
||||
|
||||
1. DB schema and migrations
|
||||
2. Config loading
|
||||
3. Crypto package (AES-256-GCM, key derivation, startup validation)
|
||||
4. Auth package (bcrypt, sessions, middleware)
|
||||
5. Login/setup/logout handlers and templates
|
||||
6. Models and DB query layer (encrypt/decrypt wired in)
|
||||
7. Apify client (with a test harness)
|
||||
8. Ntfy client
|
||||
9. Templ layout + base styles (auth-aware nav)
|
||||
10. Items list handler and template
|
||||
11. Add item Step 1 form
|
||||
12. Add item Step 2 preview handler and template
|
||||
13. Confirm save handler (wires to scheduler)
|
||||
14. Edit item handler
|
||||
15. Settings page (including password change)
|
||||
16. Scheduler wired to Apify + Ntfy + price_history writes
|
||||
17. Dashboard
|
||||
18. Per-item results page with Chart.js price history
|
||||
19. Global results view
|
||||
20. "Run Now" HTMX endpoint
|
||||
21. Graceful shutdown
|
||||
|
||||
---
|
||||
|
||||
## Agreed Deviations from this Spec
|
||||
|
||||
These items were discussed with the operator on 2026-05-10 before implementation and override the corresponding sections above. They are appended rather than edited in place to preserve the original spec text.
|
||||
|
||||
1. **Encrypted-columns list pruned.** Random-nonce AES-256-GCM produces non-deterministic ciphertext, which breaks the two equality lookups the spec itself requires: login (`SELECT ... WHERE username = ?`) and result deduplication (`SELECT ... WHERE item_id = ? AND url = ?`, spec line 565). Therefore drop encryption from these columns:
|
||||
- `users.username`
|
||||
- `items.url`
|
||||
- `results.url`
|
||||
|
||||
Keep encryption on all other columns listed in the Data Encryption table (lines 320-326): `settings.value`, `items.search_query`, `items.ntfy_topic`, `items.best_price_url`, `items.best_price_image_url`, `items.best_price_title`, `items.last_poll_error`, `results.title`, `results.image_url`, `price_history.store`.
|
||||
|
||||
2. **Sessions use `github.com/alexedwards/scs/v2`** with `github.com/alexedwards/scs/sqlite3store`, not `gorilla/sessions`. `gorilla/sessions` is in maintenance mode; `scs` is actively maintained and its sqlite store fits the existing DB. The `security.session_secret` config value is still the source of the signing key.
|
||||
|
||||
3. **CSRF protection is implemented as per-session synchronizer tokens plus `SameSite=Lax` session cookies.** The spec's note that POST-only logout is "CSRF-safe" (line 286) is incorrect on its own; a token system is required for all state-changing forms (item create/edit/delete, settings save, password change, user management, logout, pause/resume, run-now). Middleware validates the token on every non-idempotent method.
|
||||
|
||||
4. **Vendor HTMX and Chart.js into `static/`** rather than loading from a CDN. The app is self-hosted and should not break when the host has no internet. Tailwind remains on the Play CDN for v1 (per spec line 22), with a documented path to swap for a built CSS file. Google Fonts (JetBrains Mono, Outfit) remain on CDN.
|
||||
|
||||
5. **Preview results are cached in-memory for 10 minutes,** keyed on the tuple `(query, url, marketplace, listing_type, max_results)`. A re-preview within the window reuses the cached results and does not call Apify. This avoids burning Apify credits when the operator iterates on the add-item form. Cache is process-local, lost on restart.
|
||||
|
||||
6. **Tests are written for these areas only:** crypto round-trip and tamper-detection; scheduler alert-condition logic; result deduplication; deal-quality badge selection. Other code is untested for v1.
|
||||
|
||||
7. **Apify actor IDs are verified at build time before populating `config.toml.example`.** Each ID listed in the spec (lines 360, 402, 428, 453, 466, 502-505) is checked against `apify.com/store`. If an actor cannot be confirmed to exist with reasonable activity and success rate, its config key is left blank and the app fails fast at startup with a message naming the unset key. The Mercari JP actor was already flagged for verification by the spec itself (lines 488-489).
|
||||
Reference in New Issue
Block a user