Add eBay Browse API integration with daily call quota
eBay marketplaces are now polled through eBay's official Buy > Browse API (client-credentials OAuth2) instead of an Apify scraper actor; Apify still handles Yahoo JP and Mercari. Browse API calls are tracked per day in a new ebay_api_usage table and capped (default 5000, configurable) on eBay's Pacific-time reset clock, so polling halts before the limit is hit. Credentials live in config.toml [ebay] and are overridable via /settings, which also surfaces the day's running call count. Also carries the server.secure_cookies config plumbing (field, accessor, example) consumed by the following commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,11 @@
|
|||||||
[server]
|
[server]
|
||||||
port = 8080
|
port = 8080
|
||||||
db_path = "./veola.db"
|
db_path = "./veola.db"
|
||||||
|
# Sets the Secure attribute on the session cookie. Leave true for any
|
||||||
|
# HTTPS-reachable deployment, including behind a TLS-terminating proxy such as
|
||||||
|
# Traefik. Defaults to true if omitted; set false only for local plain-HTTP
|
||||||
|
# development on a non-localhost address.
|
||||||
|
secure_cookies = true
|
||||||
|
|
||||||
[security]
|
[security]
|
||||||
# Both must be at least 32 bytes and different from each other.
|
# Both must be at least 32 bytes and different from each other.
|
||||||
@@ -31,6 +36,21 @@ yahoo_auctions_jp = "meron1122/zenmarket-scraper"
|
|||||||
yahoo_auctions_jp_sold = "" # no known verified sold-listings actor for Yahoo JP
|
yahoo_auctions_jp_sold = "" # no known verified sold-listings actor for Yahoo JP
|
||||||
mercari_jp = "cloud9_ai/mercari-scraper"
|
mercari_jp = "cloud9_ai/mercari-scraper"
|
||||||
|
|
||||||
|
# eBay's official Buy > Browse API. When client_id and client_secret are set,
|
||||||
|
# eBay marketplaces (ebay.com, ebay.co.uk, ...) are polled through this API
|
||||||
|
# instead of an Apify scraper actor; Apify still handles Yahoo JP and Mercari.
|
||||||
|
# client_id is the App ID and client_secret is the Cert ID from your eBay
|
||||||
|
# developer keyset. Both can also be set/overridden at runtime via /settings.
|
||||||
|
# environment is "production" (default) or "sandbox".
|
||||||
|
# daily_call_limit caps Browse API calls per day on eBay's own quota clock
|
||||||
|
# (midnight US Pacific); once hit, eBay polling halts until the next reset.
|
||||||
|
# 5000 is the standard Browse API allowance; set a negative value to disable.
|
||||||
|
[ebay]
|
||||||
|
client_id = ""
|
||||||
|
client_secret = ""
|
||||||
|
environment = "production"
|
||||||
|
daily_call_limit = 5000
|
||||||
|
|
||||||
[ntfy]
|
[ntfy]
|
||||||
base_url = "https://ntfy.yourdomain.com"
|
base_url = "https://ntfy.yourdomain.com"
|
||||||
default_topic = "veola"
|
default_topic = "veola"
|
||||||
|
|||||||
@@ -13,13 +13,43 @@ type Config struct {
|
|||||||
Server ServerConfig `toml:"server"`
|
Server ServerConfig `toml:"server"`
|
||||||
Security SecurityConfig `toml:"security"`
|
Security SecurityConfig `toml:"security"`
|
||||||
Apify ApifyConfig `toml:"apify"`
|
Apify ApifyConfig `toml:"apify"`
|
||||||
|
Ebay EbayConfig `toml:"ebay"`
|
||||||
Ntfy NtfyConfig `toml:"ntfy"`
|
Ntfy NtfyConfig `toml:"ntfy"`
|
||||||
Scheduler SchedulerConfig `toml:"scheduler"`
|
Scheduler SchedulerConfig `toml:"scheduler"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EbayConfig holds credentials for eBay's official Buy > Browse API. When set,
|
||||||
|
// eBay marketplaces are polled through the Browse API instead of an Apify
|
||||||
|
// scraper actor. ClientID is the App ID and ClientSecret is the Cert ID from
|
||||||
|
// the eBay developer keyset. Environment is "production" (default) or
|
||||||
|
// "sandbox". Like the Apify key, both credentials can be overridden at
|
||||||
|
// runtime via the Settings page.
|
||||||
|
type EbayConfig struct {
|
||||||
|
ClientID string `toml:"client_id"`
|
||||||
|
ClientSecret string `toml:"client_secret"`
|
||||||
|
Environment string `toml:"environment"`
|
||||||
|
// DailyCallLimit caps Browse API calls per day, on eBay's own quota
|
||||||
|
// clock (midnight US Pacific). Once reached, eBay polling halts until
|
||||||
|
// the next reset. Defaults to 5000 (the standard Browse API allowance).
|
||||||
|
// Set to a negative value to disable the cap.
|
||||||
|
DailyCallLimit int `toml:"daily_call_limit"`
|
||||||
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Port int `toml:"port"`
|
Port int `toml:"port"`
|
||||||
DBPath string `toml:"db_path"`
|
DBPath string `toml:"db_path"`
|
||||||
|
// SecureCookies sets the Secure attribute on the session cookie. It must
|
||||||
|
// be true in any deployment reachable over HTTPS — including behind a
|
||||||
|
// TLS-terminating proxy like Traefik, where the browser-facing leg is
|
||||||
|
// HTTPS even though Veola itself speaks plain HTTP. Defaults to true;
|
||||||
|
// set false only for local non-TLS development.
|
||||||
|
SecureCookies *bool `toml:"secure_cookies"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UseSecureCookies resolves the SecureCookies setting, defaulting to true when
|
||||||
|
// the key is absent from config.
|
||||||
|
func (c ServerConfig) UseSecureCookies() bool {
|
||||||
|
return c.SecureCookies == nil || *c.SecureCookies
|
||||||
}
|
}
|
||||||
|
|
||||||
type SecurityConfig struct {
|
type SecurityConfig struct {
|
||||||
@@ -111,6 +141,9 @@ func (c *Config) validate() error {
|
|||||||
if c.Ntfy.DefaultTopic == "" {
|
if c.Ntfy.DefaultTopic == "" {
|
||||||
c.Ntfy.DefaultTopic = "veola"
|
c.Ntfy.DefaultTopic = "veola"
|
||||||
}
|
}
|
||||||
|
if c.Ebay.DailyCallLimit == 0 {
|
||||||
|
c.Ebay.DailyCallLimit = 5000
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -213,6 +213,60 @@ func (s *Store) SetSetting(ctx context.Context, key, value string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ ebay api usage ============
|
||||||
|
|
||||||
|
// ebayResetLoc is the timezone eBay's API rate limits reset in: midnight
|
||||||
|
// Pacific (it observes US DST). If the zone database is somehow unavailable
|
||||||
|
// we fall back to UTC rather than failing — main.go embeds time/tzdata so in
|
||||||
|
// practice the lookup always succeeds.
|
||||||
|
var ebayResetLoc = func() *time.Location {
|
||||||
|
loc, err := time.LoadLocation("America/Los_Angeles")
|
||||||
|
if err != nil {
|
||||||
|
return time.UTC
|
||||||
|
}
|
||||||
|
return loc
|
||||||
|
}()
|
||||||
|
|
||||||
|
// ebayUsageDay returns the date key used to bucket eBay API calls, aligned to
|
||||||
|
// eBay's own quota reset (midnight US Pacific).
|
||||||
|
func ebayUsageDay() string {
|
||||||
|
return time.Now().In(ebayResetLoc).Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
// EbayUsageToday returns the number of eBay Browse API calls recorded for the
|
||||||
|
// current UTC day. A missing row counts as zero.
|
||||||
|
func (s *Store) EbayUsageToday(ctx context.Context) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := s.DB.QueryRowContext(ctx,
|
||||||
|
`SELECT call_count FROM ebay_api_usage WHERE usage_date = ?`, ebayUsageDay()).Scan(&n)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrementEbayUsage records one eBay Browse API call against the current UTC
|
||||||
|
// day and returns the new running total.
|
||||||
|
func (s *Store) IncrementEbayUsage(ctx context.Context) (int, error) {
|
||||||
|
day := ebayUsageDay()
|
||||||
|
_, err := s.DB.ExecContext(ctx, `
|
||||||
|
INSERT INTO ebay_api_usage (usage_date, call_count) VALUES (?, 1)
|
||||||
|
ON CONFLICT(usage_date) DO UPDATE SET call_count = call_count + 1
|
||||||
|
`, day)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
if err := s.DB.QueryRowContext(ctx,
|
||||||
|
`SELECT call_count FROM ebay_api_usage WHERE usage_date = ?`, day).Scan(&n); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ============ items ============
|
// ============ items ============
|
||||||
|
|
||||||
func (s *Store) CreateItem(ctx context.Context, it *models.Item) (int64, error) {
|
func (s *Store) CreateItem(ctx context.Context, it *models.Item) (int64, error) {
|
||||||
|
|||||||
@@ -90,6 +90,15 @@ INSERT OR IGNORE INTO settings (key, value) VALUES
|
|||||||
('global_poll_interval_minutes', '60'),
|
('global_poll_interval_minutes', '60'),
|
||||||
('match_confidence_threshold', '0.6');
|
('match_confidence_threshold', '0.6');
|
||||||
|
|
||||||
|
-- ebay_api_usage tracks Browse API calls per day so Veola can surface
|
||||||
|
-- consumption and halt polling before the developer keyset's daily call
|
||||||
|
-- limit is exceeded. usage_date is YYYY-MM-DD in US Pacific time, matching
|
||||||
|
-- eBay's own quota reset.
|
||||||
|
CREATE TABLE IF NOT EXISTS ebay_api_usage (
|
||||||
|
usage_date TEXT PRIMARY KEY,
|
||||||
|
call_count INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
token TEXT PRIMARY KEY,
|
token TEXT PRIMARY KEY,
|
||||||
data BLOB NOT NULL,
|
data BLOB NOT NULL,
|
||||||
|
|||||||
227
internal/ebay/client.go
Normal file
227
internal/ebay/client.go
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
// Package ebay is a thin client for eBay's official Buy > Browse API. It
|
||||||
|
// handles client-credentials OAuth2 token caching and active-listing search
|
||||||
|
// (item_summary/search). It deliberately covers only what Veola needs: a
|
||||||
|
// keyword search returning normalized listings. Sold/completed data (the
|
||||||
|
// Marketplace Insights API) is not implemented here.
|
||||||
|
package ebay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// endpoints bundles the production/sandbox base URLs for the two APIs used.
|
||||||
|
type endpoints struct {
|
||||||
|
oauth string
|
||||||
|
browse string
|
||||||
|
}
|
||||||
|
|
||||||
|
func endpointsFor(environment string) endpoints {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(environment), "sandbox") {
|
||||||
|
return endpoints{
|
||||||
|
oauth: "https://api.sandbox.ebay.com/identity/v1/oauth2/token",
|
||||||
|
browse: "https://api.sandbox.ebay.com/buy/browse/v1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return endpoints{
|
||||||
|
oauth: "https://api.ebay.com/identity/v1/oauth2/token",
|
||||||
|
browse: "https://api.ebay.com/buy/browse/v1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client is safe for concurrent use. The application access token is cached
|
||||||
|
// in memory and refreshed shortly before it expires.
|
||||||
|
type Client struct {
|
||||||
|
HTTP *http.Client
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
clientID string
|
||||||
|
clientSecret string
|
||||||
|
ends endpoints
|
||||||
|
token string
|
||||||
|
tokenExpiry time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a client for the given keyset. environment is "production"
|
||||||
|
// (default) or "sandbox". Credentials may be empty; calls then fail fast
|
||||||
|
// with a "not configured" error.
|
||||||
|
func New(clientID, clientSecret, environment string) *Client {
|
||||||
|
return &Client{
|
||||||
|
HTTP: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
clientID: clientID,
|
||||||
|
clientSecret: clientSecret,
|
||||||
|
ends: endpointsFor(environment),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureCredentials updates the keyset if it changed, discarding any cached
|
||||||
|
// token so the next call re-authenticates. The environment is fixed at
|
||||||
|
// construction time and is not changed here. Safe to call on every poll.
|
||||||
|
func (c *Client) EnsureCredentials(clientID, clientSecret string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if clientID == c.clientID && clientSecret == c.clientSecret {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.clientID = clientID
|
||||||
|
c.clientSecret = clientSecret
|
||||||
|
c.token = ""
|
||||||
|
c.tokenExpiry = time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) accessToken(ctx context.Context) (string, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.clientID == "" || c.clientSecret == "" {
|
||||||
|
return "", errors.New("ebay credentials not configured")
|
||||||
|
}
|
||||||
|
if c.token != "" && time.Now().Before(c.tokenExpiry) {
|
||||||
|
return c.token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("grant_type", "client_credentials")
|
||||||
|
form.Set("scope", "https://api.ebay.com/oauth/api_scope")
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.ends.oauth, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
basic := base64.StdEncoding.EncodeToString([]byte(c.clientID + ":" + c.clientSecret))
|
||||||
|
req.Header.Set("Authorization", "Basic "+basic)
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
resp, err := c.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ebay oauth: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return "", fmt.Errorf("ebay oauth: http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
var tr struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &tr); err != nil {
|
||||||
|
return "", fmt.Errorf("ebay oauth: decode: %w", err)
|
||||||
|
}
|
||||||
|
if tr.AccessToken == "" {
|
||||||
|
return "", errors.New("ebay oauth: empty access token")
|
||||||
|
}
|
||||||
|
c.token = tr.AccessToken
|
||||||
|
// Refresh a minute early to avoid racing the expiry.
|
||||||
|
ttl := time.Duration(tr.ExpiresIn) * time.Second
|
||||||
|
if ttl <= time.Minute {
|
||||||
|
ttl = time.Minute
|
||||||
|
}
|
||||||
|
c.tokenExpiry = time.Now().Add(ttl - time.Minute)
|
||||||
|
return c.token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// browseItemSummary mirrors the subset of the item_summary/search response
|
||||||
|
// Veola consumes.
|
||||||
|
type browseItemSummary struct {
|
||||||
|
ItemID string `json:"itemId"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Price struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
} `json:"price"`
|
||||||
|
ItemWebURL string `json:"itemWebUrl"`
|
||||||
|
Image struct {
|
||||||
|
ImageURL string `json:"imageUrl"`
|
||||||
|
} `json:"image"`
|
||||||
|
ThumbnailImages []struct {
|
||||||
|
ImageURL string `json:"imageUrl"`
|
||||||
|
} `json:"thumbnailImages"`
|
||||||
|
Seller struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
} `json:"seller"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search runs one item_summary/search call and returns normalized listings.
|
||||||
|
// An empty query is rejected: the Browse API requires a non-empty q.
|
||||||
|
func (c *Client) Search(ctx context.Context, p SearchParams) ([]Listing, error) {
|
||||||
|
query := strings.TrimSpace(p.Query)
|
||||||
|
if query == "" {
|
||||||
|
return nil, errors.New("ebay search requires a non-empty query")
|
||||||
|
}
|
||||||
|
token, err := c.accessToken(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
marketplace := p.MarketplaceID
|
||||||
|
if marketplace == "" {
|
||||||
|
marketplace = "EBAY_US"
|
||||||
|
}
|
||||||
|
limit := p.Limit
|
||||||
|
if limit <= 0 || limit > 200 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("q", query)
|
||||||
|
q.Set("limit", strconv.Itoa(limit))
|
||||||
|
if f := buyingOptionsFilter(p.ListingType); f != "" {
|
||||||
|
q.Set("filter", f)
|
||||||
|
}
|
||||||
|
reqURL := c.ends.browse + "/item_summary/search?" + q.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("X-EBAY-C-MARKETPLACE-ID", marketplace)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ebay browse: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("ebay browse: http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
var sr struct {
|
||||||
|
ItemSummaries []browseItemSummary `json:"itemSummaries"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &sr); err != nil {
|
||||||
|
return nil, fmt.Errorf("ebay browse: decode: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]Listing, 0, len(sr.ItemSummaries))
|
||||||
|
for _, s := range sr.ItemSummaries {
|
||||||
|
price, _ := strconv.ParseFloat(strings.TrimSpace(s.Price.Value), 64)
|
||||||
|
img := s.Image.ImageURL
|
||||||
|
if img == "" && len(s.ThumbnailImages) > 0 {
|
||||||
|
img = s.ThumbnailImages[0].ImageURL
|
||||||
|
}
|
||||||
|
store := "ebay"
|
||||||
|
if s.Seller.Username != "" {
|
||||||
|
store = "ebay (" + s.Seller.Username + ")"
|
||||||
|
}
|
||||||
|
out = append(out, Listing{
|
||||||
|
Title: s.Title,
|
||||||
|
Price: price,
|
||||||
|
Currency: s.Price.Currency,
|
||||||
|
URL: s.ItemWebURL,
|
||||||
|
Store: store,
|
||||||
|
ImageURL: img,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
45
internal/ebay/ebay_test.go
Normal file
45
internal/ebay/ebay_test.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package ebay
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestMarketplaceID(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"ebay.com": "EBAY_US",
|
||||||
|
"ebay.co.uk": "EBAY_GB",
|
||||||
|
"ebay.de": "EBAY_DE",
|
||||||
|
"ebay.com.au": "EBAY_AU",
|
||||||
|
"EBAY.CA": "EBAY_CA",
|
||||||
|
"ebay": "EBAY_US",
|
||||||
|
"weird-market": "EBAY_US",
|
||||||
|
" ebay.it ": "EBAY_IT",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := MarketplaceID(in); got != want {
|
||||||
|
t.Errorf("MarketplaceID(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsEbayMarketplace(t *testing.T) {
|
||||||
|
if !IsEbayMarketplace("ebay.co.uk") {
|
||||||
|
t.Error("ebay.co.uk should be an eBay marketplace")
|
||||||
|
}
|
||||||
|
if IsEbayMarketplace("yahoo-auctions-jp") {
|
||||||
|
t.Error("yahoo should not be an eBay marketplace")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuyingOptionsFilter(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"": "",
|
||||||
|
"all": "",
|
||||||
|
"bin": "buyingOptions:{FIXED_PRICE}",
|
||||||
|
"buy_it_now": "buyingOptions:{FIXED_PRICE}",
|
||||||
|
"auction": "buyingOptions:{AUCTION}",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := buyingOptionsFilter(in); got != want {
|
||||||
|
t.Errorf("buyingOptionsFilter(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
94
internal/ebay/types.go
Normal file
94
internal/ebay/types.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package ebay
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// SearchParams is the input to a single Browse API item_summary/search call.
|
||||||
|
// It is provider-specific and is carried as the opaque input payload of a
|
||||||
|
// scheduler plan, mirroring how Apify actor inputs are carried.
|
||||||
|
type SearchParams struct {
|
||||||
|
// MarketplaceID is an eBay marketplace identifier such as EBAY_US.
|
||||||
|
MarketplaceID string
|
||||||
|
// Query is the keyword search string. Required; the Browse API rejects
|
||||||
|
// an empty q.
|
||||||
|
Query string
|
||||||
|
// ListingType is Veola's vocabulary ("all", "bin"/"buy_it_now",
|
||||||
|
// "auction"); it is mapped to a buyingOptions filter.
|
||||||
|
ListingType string
|
||||||
|
// Limit caps the number of results requested (Browse API max is 200).
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listing is one normalized active eBay listing. The scheduler converts these
|
||||||
|
// into the shared apify.UnifiedResult shape so the rest of the pipeline
|
||||||
|
// (dedup, filter, alert) is provider-agnostic.
|
||||||
|
type Listing struct {
|
||||||
|
Title string
|
||||||
|
Price float64
|
||||||
|
Currency string
|
||||||
|
URL string
|
||||||
|
Store string
|
||||||
|
ImageURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarketplaceID maps a Veola marketplace string (e.g. "ebay.com",
|
||||||
|
// "ebay.co.uk") to an eBay Browse API marketplace identifier. Unknown or
|
||||||
|
// bare "ebay" values fall back to EBAY_US.
|
||||||
|
func MarketplaceID(marketplace string) string {
|
||||||
|
m := strings.ToLower(strings.TrimSpace(marketplace))
|
||||||
|
switch {
|
||||||
|
case strings.Contains(m, "ebay.co.uk"):
|
||||||
|
return "EBAY_GB"
|
||||||
|
case strings.Contains(m, "ebay.de"):
|
||||||
|
return "EBAY_DE"
|
||||||
|
case strings.Contains(m, "ebay.com.au"):
|
||||||
|
return "EBAY_AU"
|
||||||
|
case strings.Contains(m, "ebay.ca"):
|
||||||
|
return "EBAY_CA"
|
||||||
|
case strings.Contains(m, "ebay.fr"):
|
||||||
|
return "EBAY_FR"
|
||||||
|
case strings.Contains(m, "ebay.it"):
|
||||||
|
return "EBAY_IT"
|
||||||
|
case strings.Contains(m, "ebay.es"):
|
||||||
|
return "EBAY_ES"
|
||||||
|
case strings.Contains(m, "ebay.at"):
|
||||||
|
return "EBAY_AT"
|
||||||
|
case strings.Contains(m, "ebay.ch"):
|
||||||
|
return "EBAY_CH"
|
||||||
|
case strings.Contains(m, "ebay.ie"):
|
||||||
|
return "EBAY_IE"
|
||||||
|
case strings.Contains(m, "ebay.nl"):
|
||||||
|
return "EBAY_NL"
|
||||||
|
case strings.Contains(m, "ebay.com.hk"):
|
||||||
|
return "EBAY_HK"
|
||||||
|
case strings.Contains(m, "ebay.com.sg"):
|
||||||
|
return "EBAY_SG"
|
||||||
|
case strings.Contains(m, "ebay.com.my"):
|
||||||
|
return "EBAY_MY"
|
||||||
|
case strings.Contains(m, "ebay.ph"):
|
||||||
|
return "EBAY_PH"
|
||||||
|
case strings.Contains(m, "ebay.pl"):
|
||||||
|
return "EBAY_PL"
|
||||||
|
default:
|
||||||
|
// "ebay.com" and any bare/unknown eBay marketplace.
|
||||||
|
return "EBAY_US"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEbayMarketplace reports whether a Veola marketplace string should be
|
||||||
|
// polled through the official eBay Browse API.
|
||||||
|
func IsEbayMarketplace(marketplace string) bool {
|
||||||
|
return strings.Contains(strings.ToLower(marketplace), "ebay")
|
||||||
|
}
|
||||||
|
|
||||||
|
// buyingOptionsFilter maps Veola's listing-type vocabulary to the Browse API
|
||||||
|
// "filter" query parameter. An empty string means no filter ("all").
|
||||||
|
func buyingOptionsFilter(listingType string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(listingType)) {
|
||||||
|
case "bin", "buy_it_now", "fixed_price":
|
||||||
|
return "buyingOptions:{FIXED_PRICE}"
|
||||||
|
case "auction":
|
||||||
|
return "buyingOptions:{AUCTION}"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,12 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"veola/internal/apify"
|
"veola/internal/apify"
|
||||||
"veola/internal/auth"
|
"veola/internal/auth"
|
||||||
|
"veola/internal/ebay"
|
||||||
"veola/internal/models"
|
"veola/internal/models"
|
||||||
"veola/internal/ntfy"
|
"veola/internal/ntfy"
|
||||||
"veola/templates"
|
"veola/templates"
|
||||||
@@ -14,6 +16,9 @@ import (
|
|||||||
|
|
||||||
var settingsKeys = []string{
|
var settingsKeys = []string{
|
||||||
"apify_api_key",
|
"apify_api_key",
|
||||||
|
"ebay_client_id",
|
||||||
|
"ebay_client_secret",
|
||||||
|
"ebay_daily_call_limit",
|
||||||
"ntfy_base_url",
|
"ntfy_base_url",
|
||||||
"ntfy_default_topic",
|
"ntfy_default_topic",
|
||||||
"ntfy_token",
|
"ntfy_token",
|
||||||
@@ -31,11 +36,14 @@ func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
|
|||||||
}
|
}
|
||||||
users, _ := a.Store.ListUsers(r.Context())
|
users, _ := a.Store.ListUsers(r.Context())
|
||||||
cur := auth.CurrentUserFromRequest(r)
|
cur := auth.CurrentUserFromRequest(r)
|
||||||
|
ebayUsed, ebayLimit := a.Scheduler.EbayUsage(r.Context())
|
||||||
return templates.SettingsData{
|
return templates.SettingsData{
|
||||||
Page: a.page(r, "Settings", "settings"),
|
Page: a.page(r, "Settings", "settings"),
|
||||||
Values: values,
|
Values: values,
|
||||||
IsAdmin: cur != nil && cur.Role == models.RoleAdmin,
|
IsAdmin: cur != nil && cur.Role == models.RoleAdmin,
|
||||||
Users: users,
|
Users: users,
|
||||||
|
EbayUsedToday: ebayUsed,
|
||||||
|
EbayDailyLimit: ebayLimit,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,3 +201,90 @@ func (a *App) PostTestApify(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
render(w, r, templates.Settings(d))
|
render(w, r, templates.Settings(d))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) PostTestEbay(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cur := auth.CurrentUserFromRequest(r)
|
||||||
|
if cur == nil || cur.Role != models.RoleAdmin {
|
||||||
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d, err := a.settingsData(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Settings-table values win over config.toml. Both paths are trimmed:
|
||||||
|
// a stray newline in the TOML would otherwise reach eBay verbatim.
|
||||||
|
clientID := strings.TrimSpace(d.Values["ebay_client_id"])
|
||||||
|
if clientID == "" {
|
||||||
|
clientID = strings.TrimSpace(a.Cfg.Ebay.ClientID)
|
||||||
|
}
|
||||||
|
clientSecret := strings.TrimSpace(d.Values["ebay_client_secret"])
|
||||||
|
if clientSecret == "" {
|
||||||
|
clientSecret = strings.TrimSpace(a.Cfg.Ebay.ClientSecret)
|
||||||
|
}
|
||||||
|
if clientID == "" || clientSecret == "" {
|
||||||
|
d.TestEbayOK = "Set the eBay App ID and Cert ID first."
|
||||||
|
render(w, r, templates.Settings(d))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if d.EbayDailyLimit > 0 && d.EbayUsedToday >= d.EbayDailyLimit {
|
||||||
|
d.TestEbayOK = fmt.Sprintf("Daily eBay API call limit reached (%d/%d). Test skipped.", d.EbayUsedToday, d.EbayDailyLimit)
|
||||||
|
render(w, r, templates.Settings(d))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
env := "production"
|
||||||
|
if strings.EqualFold(strings.TrimSpace(a.Cfg.Ebay.Environment), "sandbox") {
|
||||||
|
env = "sandbox"
|
||||||
|
}
|
||||||
|
// Echo back exactly what was sent (App ID masked, Cert ID length only) so
|
||||||
|
// a failure points at the inputs, not just "it failed".
|
||||||
|
inputs := fmt.Sprintf("%s, App ID %s, Cert ID %d chars", env, maskID(clientID), len(clientSecret))
|
||||||
|
|
||||||
|
client := ebay.New(clientID, clientSecret, a.Cfg.Ebay.Environment)
|
||||||
|
listings, err := client.Search(r.Context(), ebay.SearchParams{
|
||||||
|
MarketplaceID: "EBAY_US",
|
||||||
|
Query: "test",
|
||||||
|
Limit: 1,
|
||||||
|
})
|
||||||
|
// A real call was made; count it against the daily allowance.
|
||||||
|
if n, incErr := a.Store.IncrementEbayUsage(r.Context()); incErr == nil {
|
||||||
|
d.EbayUsedToday = n
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
msg := fmt.Sprintf("eBay test failed (%s): %s", inputs, err.Error())
|
||||||
|
if ks := ebayKeysetEnv(clientID); ks != "" && ks != env {
|
||||||
|
msg += fmt.Sprintf(" — the App ID looks like a %s keyset, but environment is %q. Set environment = %q in the [ebay] config block (or use your %s keyset).", ks, env, ks, env)
|
||||||
|
}
|
||||||
|
d.TestEbayOK = msg
|
||||||
|
} else {
|
||||||
|
d.TestEbayOK = fmt.Sprintf("eBay Browse API reachable (%s). Returned %d item(s).", inputs, len(listings))
|
||||||
|
}
|
||||||
|
render(w, r, templates.Settings(d))
|
||||||
|
}
|
||||||
|
|
||||||
|
// maskID returns a fingerprint of a credential for display: enough of the head
|
||||||
|
// to recognize it, the rest elided. Used only for the App ID (the non-secret
|
||||||
|
// half of the OAuth pair) — never for the Cert ID.
|
||||||
|
func maskID(s string) string {
|
||||||
|
if len(s) <= 12 {
|
||||||
|
return strings.Repeat("•", len(s))
|
||||||
|
}
|
||||||
|
return s[:12] + "…(" + strconv.Itoa(len(s)) + " chars)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ebayKeysetEnv guesses which environment an eBay App ID belongs to from the
|
||||||
|
// SBX/PRD marker eBay embeds in it (e.g. "Name-app-PRD-1a2b..."). Returns ""
|
||||||
|
// when no marker is present.
|
||||||
|
func ebayKeysetEnv(clientID string) string {
|
||||||
|
up := strings.ToUpper(clientID)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(up, "-SBX-"):
|
||||||
|
return "sandbox"
|
||||||
|
case strings.Contains(up, "-PRD-"):
|
||||||
|
return "production"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -13,14 +14,23 @@ import (
|
|||||||
"veola/internal/apify"
|
"veola/internal/apify"
|
||||||
"veola/internal/config"
|
"veola/internal/config"
|
||||||
"veola/internal/db"
|
"veola/internal/db"
|
||||||
|
"veola/internal/ebay"
|
||||||
"veola/internal/models"
|
"veola/internal/models"
|
||||||
"veola/internal/ntfy"
|
"veola/internal/ntfy"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Provider labels distinguish how a plan is executed: through an Apify actor
|
||||||
|
// run, or through eBay's official Browse API.
|
||||||
|
const (
|
||||||
|
providerApify = "apify"
|
||||||
|
providerEbay = "ebay"
|
||||||
|
)
|
||||||
|
|
||||||
type Scheduler struct {
|
type Scheduler struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
store *db.Store
|
store *db.Store
|
||||||
apify *apify.Client
|
apify *apify.Client
|
||||||
|
ebay *ebay.Client
|
||||||
ntfy *ntfy.Client
|
ntfy *ntfy.Client
|
||||||
cron *cron.Cron
|
cron *cron.Cron
|
||||||
|
|
||||||
@@ -37,6 +47,7 @@ func New(cfg *config.Config, store *db.Store, ap *apify.Client, nt *ntfy.Client)
|
|||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
store: store,
|
store: store,
|
||||||
apify: ap,
|
apify: ap,
|
||||||
|
ebay: ebay.New(cfg.Ebay.ClientID, cfg.Ebay.ClientSecret, cfg.Ebay.Environment),
|
||||||
ntfy: nt,
|
ntfy: nt,
|
||||||
cron: cron.New(),
|
cron: cron.New(),
|
||||||
entries: make(map[int64]cron.EntryID),
|
entries: make(map[int64]cron.EntryID),
|
||||||
@@ -136,52 +147,16 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
|
|||||||
var errs []string
|
var errs []string
|
||||||
successes := 0
|
successes := 0
|
||||||
for _, p := range plans {
|
for _, p := range plans {
|
||||||
if p.actorID == "" {
|
decoded, err := s.ExecutePlan(ctx, p)
|
||||||
errs = append(errs, fmt.Sprintf("%s: no actor configured", p.marketplace))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
raw, err := apifyClient.Run(ctx, p.actorID, p.input)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
label := p.marketplace
|
label := p.marketplace
|
||||||
if p.query != "" {
|
if p.query != "" {
|
||||||
label = fmt.Sprintf("query %q on %s", p.query, p.marketplace)
|
label = fmt.Sprintf("query %q on %s", p.query, p.marketplace)
|
||||||
}
|
}
|
||||||
errs = append(errs, fmt.Sprintf("%s: %s", label, err.Error()))
|
errs = append(errs, fmt.Sprintf("%s: %s", label, err.Error()))
|
||||||
slog.Error("apify run failed", "item_id", it.ID, "marketplace", p.marketplace, "query", p.query, "err", err)
|
slog.Error("plan failed", "item_id", it.ID, "provider", p.provider, "marketplace", p.marketplace, "query", p.query, "err", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
decoded, _ := apify.Decode(raw, p.source)
|
|
||||||
usable := 0
|
|
||||||
for i := range decoded {
|
|
||||||
decoded[i].MatchedQuery = p.query
|
|
||||||
if decoded[i].URL != "" && decoded[i].Price > 0 {
|
|
||||||
usable++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
slog.Info("apify run decoded",
|
|
||||||
"item_id", it.ID,
|
|
||||||
"marketplace", p.marketplace,
|
|
||||||
"query", p.query,
|
|
||||||
"actor", p.actorID,
|
|
||||||
"raw", len(raw),
|
|
||||||
"decoded", len(decoded),
|
|
||||||
"usable", usable,
|
|
||||||
)
|
|
||||||
if usable == 0 && len(raw) > 0 {
|
|
||||||
var sample map[string]any
|
|
||||||
if err := jsonUnmarshal(raw[0], &sample); err == nil {
|
|
||||||
keys := make([]string, 0, len(sample))
|
|
||||||
for k := range sample {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
slog.Warn("decoded zero usable rows; raw item keys",
|
|
||||||
"item_id", it.ID,
|
|
||||||
"marketplace", p.marketplace,
|
|
||||||
"actor", p.actorID,
|
|
||||||
"keys", keys,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
results = append(results, decoded...)
|
results = append(results, decoded...)
|
||||||
successes++
|
successes++
|
||||||
}
|
}
|
||||||
@@ -322,6 +297,103 @@ func (s *Scheduler) apifyClient(ctx context.Context) *apify.Client {
|
|||||||
return apify.New(key)
|
return apify.New(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ebayClient returns the shared eBay client with credentials refreshed from
|
||||||
|
// settings (falling back to config.toml). The client caches its OAuth token
|
||||||
|
// in memory, so the same instance is reused across polls; credentials are
|
||||||
|
// only re-applied when they actually change.
|
||||||
|
func (s *Scheduler) ebayClient(ctx context.Context) *ebay.Client {
|
||||||
|
id := s.cfg.Ebay.ClientID
|
||||||
|
secret := s.cfg.Ebay.ClientSecret
|
||||||
|
if v, _ := s.store.GetSetting(ctx, "ebay_client_id"); v != "" {
|
||||||
|
id = v
|
||||||
|
}
|
||||||
|
if v, _ := s.store.GetSetting(ctx, "ebay_client_secret"); v != "" {
|
||||||
|
secret = v
|
||||||
|
}
|
||||||
|
s.ebay.EnsureCredentials(id, secret)
|
||||||
|
return s.ebay
|
||||||
|
}
|
||||||
|
|
||||||
|
// EbayUsage returns the number of eBay Browse API calls made so far today and
|
||||||
|
// the configured daily limit. A limit <= 0 means uncapped. Settings override
|
||||||
|
// config.toml for the limit, mirroring how credentials are resolved.
|
||||||
|
func (s *Scheduler) EbayUsage(ctx context.Context) (used, limit int) {
|
||||||
|
used, _ = s.store.EbayUsageToday(ctx)
|
||||||
|
limit = s.cfg.Ebay.DailyCallLimit
|
||||||
|
if v, _ := s.store.GetSetting(ctx, "ebay_daily_call_limit"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return used, limit
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecutePlan runs one plan and returns decoded, provider-agnostic results
|
||||||
|
// with MatchedQuery already stamped. eBay plans go through the official
|
||||||
|
// Browse API; all other plans run an Apify actor. Callers handle per-plan
|
||||||
|
// errors without poisoning sibling plans.
|
||||||
|
func (s *Scheduler) ExecutePlan(ctx context.Context, p actorPlan) ([]apify.UnifiedResult, error) {
|
||||||
|
var decoded []apify.UnifiedResult
|
||||||
|
switch p.provider {
|
||||||
|
case providerEbay:
|
||||||
|
sp, ok := p.input.(ebay.SearchParams)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("ebay plan has wrong input type %T", p.input)
|
||||||
|
}
|
||||||
|
used, limit := s.EbayUsage(ctx)
|
||||||
|
if limit > 0 && used >= limit {
|
||||||
|
return nil, fmt.Errorf("ebay daily API call limit reached (%d/%d); polling halted until the next reset (midnight US Pacific)", used, limit)
|
||||||
|
}
|
||||||
|
listings, err := s.ebayClient(ctx).Search(ctx, sp)
|
||||||
|
// The call hit eBay (or at least was attempted against it) whether
|
||||||
|
// or not it succeeded, so it counts against the daily allowance.
|
||||||
|
if n, incErr := s.store.IncrementEbayUsage(ctx); incErr != nil {
|
||||||
|
slog.Error("ebay usage increment failed", "err", incErr)
|
||||||
|
} else if limit > 0 && n >= limit {
|
||||||
|
slog.Warn("ebay daily API call limit reached", "used", n, "limit", limit)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
decoded = make([]apify.UnifiedResult, 0, len(listings))
|
||||||
|
for _, l := range listings {
|
||||||
|
decoded = append(decoded, apify.UnifiedResult{
|
||||||
|
Title: l.Title,
|
||||||
|
Price: l.Price,
|
||||||
|
Currency: l.Currency,
|
||||||
|
URL: l.URL,
|
||||||
|
Store: l.Store,
|
||||||
|
ImageURL: l.ImageURL,
|
||||||
|
Source: apify.SourceActiveEbay,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if p.actorID == "" {
|
||||||
|
return nil, fmt.Errorf("no actor configured for %s", p.marketplace)
|
||||||
|
}
|
||||||
|
raw, err := s.apifyClient(ctx).Run(ctx, p.actorID, p.input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
decoded, _ = apify.Decode(raw, p.source)
|
||||||
|
}
|
||||||
|
usable := 0
|
||||||
|
for i := range decoded {
|
||||||
|
decoded[i].MatchedQuery = p.query
|
||||||
|
if decoded[i].URL != "" && decoded[i].Price > 0 {
|
||||||
|
usable++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slog.Info("plan executed",
|
||||||
|
"provider", p.provider,
|
||||||
|
"marketplace", p.marketplace,
|
||||||
|
"query", p.query,
|
||||||
|
"decoded", len(decoded),
|
||||||
|
"usable", usable,
|
||||||
|
)
|
||||||
|
return decoded, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Scheduler) sendNotification(ctx context.Context, it models.Item, r apify.UnifiedResult) error {
|
func (s *Scheduler) sendNotification(ctx context.Context, it models.Item, r apify.UnifiedResult) error {
|
||||||
tags := []string{"mag"}
|
tags := []string{"mag"}
|
||||||
if it.TargetPrice != nil && r.Price <= *it.TargetPrice {
|
if it.TargetPrice != nil && r.Price <= *it.TargetPrice {
|
||||||
@@ -398,6 +470,7 @@ func (s *Scheduler) BuildPreviewInputs(it models.Item) []actorPlan {
|
|||||||
type actorPlan struct {
|
type actorPlan struct {
|
||||||
marketplace string
|
marketplace string
|
||||||
source string
|
source string
|
||||||
|
provider string
|
||||||
actorID string
|
actorID string
|
||||||
query string
|
query string
|
||||||
input any
|
input any
|
||||||
@@ -418,6 +491,9 @@ func (p actorPlan) Query() string { return p.query }
|
|||||||
// Input returns the actor input payload as expected by apify.Client.Run.
|
// Input returns the actor input payload as expected by apify.Client.Run.
|
||||||
func (p actorPlan) Input() any { return p.input }
|
func (p actorPlan) Input() any { return p.input }
|
||||||
|
|
||||||
|
// Provider returns "apify" or "ebay" — how this plan is executed.
|
||||||
|
func (p actorPlan) Provider() string { return p.provider }
|
||||||
|
|
||||||
// buildAllInputs returns one actor plan per (alias × marketplace) for the item.
|
// buildAllInputs returns one actor plan per (alias × marketplace) for the item.
|
||||||
// For URL-only items (no aliases), produces one plan per marketplace with an
|
// For URL-only items (no aliases), produces one plan per marketplace with an
|
||||||
// empty query string.
|
// empty query string.
|
||||||
@@ -447,27 +523,51 @@ func (s *Scheduler) buildInputsForQuery(it models.Item, query string, markets []
|
|||||||
switch {
|
switch {
|
||||||
case strings.Contains(mk, "yahoo") || strings.Contains(url, "yahoo.co.jp"):
|
case strings.Contains(mk, "yahoo") || strings.Contains(url, "yahoo.co.jp"):
|
||||||
actorID := firstNonEmpty(it.ActorActive, s.cfg.Apify.Actors.YahooAuctionsJP)
|
actorID := firstNonEmpty(it.ActorActive, s.cfg.Apify.Actors.YahooAuctionsJP)
|
||||||
plans = append(plans, actorPlan{m, apify.SourceYahooJP, actorID, query, apify.YahooAuctionsJPInput{
|
plans = append(plans, actorPlan{
|
||||||
SearchTerm: query,
|
marketplace: m, source: apify.SourceYahooJP, provider: providerApify,
|
||||||
MaxPages: 1,
|
actorID: actorID, query: query,
|
||||||
}})
|
input: apify.YahooAuctionsJPInput{SearchTerm: query, MaxPages: 1},
|
||||||
|
})
|
||||||
case strings.Contains(mk, "mercari") || strings.Contains(url, "mercari"):
|
case strings.Contains(mk, "mercari") || strings.Contains(url, "mercari"):
|
||||||
actorID := firstNonEmpty(it.ActorActive, s.cfg.Apify.Actors.MercariJP)
|
actorID := firstNonEmpty(it.ActorActive, s.cfg.Apify.Actors.MercariJP)
|
||||||
plans = append(plans, actorPlan{m, apify.SourceMercariJP, actorID, query, apify.MercariJPInput{
|
plans = append(plans, actorPlan{
|
||||||
SearchKeywords: []string{query},
|
marketplace: m, source: apify.SourceMercariJP, provider: providerApify,
|
||||||
Status: "on_sale",
|
actorID: actorID, query: query,
|
||||||
MaxResults: 30,
|
input: apify.MercariJPInput{
|
||||||
}})
|
SearchKeywords: []string{query},
|
||||||
|
Status: "on_sale",
|
||||||
|
MaxResults: 30,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
case ebay.IsEbayMarketplace(mk):
|
||||||
|
// eBay marketplaces are polled through eBay's official Browse
|
||||||
|
// API, not an Apify scraper actor.
|
||||||
|
plans = append(plans, actorPlan{
|
||||||
|
marketplace: m, source: apify.SourceActiveEbay, provider: providerEbay,
|
||||||
|
query: query,
|
||||||
|
input: ebay.SearchParams{
|
||||||
|
MarketplaceID: ebay.MarketplaceID(mk),
|
||||||
|
Query: query,
|
||||||
|
ListingType: it.ListingType,
|
||||||
|
Limit: 30,
|
||||||
|
},
|
||||||
|
})
|
||||||
default:
|
default:
|
||||||
|
// Non-eBay custom marketplaces still fall back to the Apify
|
||||||
|
// active-listings actor.
|
||||||
actorID := firstNonEmpty(it.ActorActive, s.cfg.Apify.Actors.ActiveListings)
|
actorID := firstNonEmpty(it.ActorActive, s.cfg.Apify.Actors.ActiveListings)
|
||||||
plans = append(plans, actorPlan{m, apify.SourceActiveEbay, actorID, query, apify.ActiveListingInput{
|
plans = append(plans, actorPlan{
|
||||||
SearchQueries: []string{query},
|
marketplace: m, source: apify.SourceActiveEbay, provider: providerApify,
|
||||||
MaxProductsPerSearch: 30,
|
actorID: actorID, query: query,
|
||||||
MaxSearchPages: 1,
|
input: apify.ActiveListingInput{
|
||||||
Sort: "best_match",
|
SearchQueries: []string{query},
|
||||||
ListingType: mapListingType(it.ListingType),
|
MaxProductsPerSearch: 30,
|
||||||
ProxyConfiguration: s.proxyConfig(),
|
MaxSearchPages: 1,
|
||||||
}})
|
Sort: "best_match",
|
||||||
|
ListingType: mapListingType(it.ListingType),
|
||||||
|
ProxyConfiguration: s.proxyConfig(),
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return plans
|
return plans
|
||||||
|
|||||||
5
main.go
5
main.go
@@ -11,6 +11,9 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
// Embed the timezone database so eBay's Pacific-time quota reset resolves
|
||||||
|
// correctly even on minimal hosts without system zoneinfo.
|
||||||
|
_ "time/tzdata"
|
||||||
|
|
||||||
"veola/internal/apify"
|
"veola/internal/apify"
|
||||||
"veola/internal/auth"
|
"veola/internal/auth"
|
||||||
@@ -50,7 +53,7 @@ func run(configPath string) error {
|
|||||||
defer sqlDB.Close()
|
defer sqlDB.Close()
|
||||||
|
|
||||||
store := db.NewStore(sqlDB, key)
|
store := db.NewStore(sqlDB, key)
|
||||||
authMgr, err := auth.NewManager(sqlDB, store, cfg.Security.SessionSecret)
|
authMgr, err := auth.NewManager(sqlDB, store, cfg.Security.SessionSecret, cfg.Server.UseSecureCookies())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("auth manager: %w", err)
|
return fmt.Errorf("auth manager: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,15 +8,24 @@ import (
|
|||||||
|
|
||||||
type SettingsData struct {
|
type SettingsData struct {
|
||||||
Page
|
Page
|
||||||
Values map[string]string
|
Values map[string]string
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
Users []models.User
|
Users []models.User
|
||||||
TestNtfyOK string
|
TestNtfyOK string
|
||||||
TestApifyOK string
|
TestApifyOK string
|
||||||
PasswordMsg string
|
TestEbayOK string
|
||||||
PasswordError string
|
EbayUsedToday int
|
||||||
UserMsg string
|
EbayDailyLimit int
|
||||||
UserError string
|
PasswordMsg string
|
||||||
|
PasswordError string
|
||||||
|
UserMsg string
|
||||||
|
UserError string
|
||||||
|
}
|
||||||
|
|
||||||
|
// EbayLimitReached reports whether eBay polling is currently halted because
|
||||||
|
// the daily call limit has been hit.
|
||||||
|
func (d SettingsData) EbayLimitReached() bool {
|
||||||
|
return d.EbayDailyLimit > 0 && d.EbayUsedToday >= d.EbayDailyLimit
|
||||||
}
|
}
|
||||||
|
|
||||||
templ settingsBody(d SettingsData) {
|
templ settingsBody(d SettingsData) {
|
||||||
@@ -24,14 +33,37 @@ templ settingsBody(d SettingsData) {
|
|||||||
<h1 class="text-3xl font-semibold">Settings</h1>
|
<h1 class="text-3xl font-semibold">Settings</h1>
|
||||||
|
|
||||||
<section class="v-card p-6">
|
<section class="v-card p-6">
|
||||||
<h2 class="font-semibold mb-4">Apify and Ntfy</h2>
|
<h2 class="font-semibold mb-4">Apify, eBay and Ntfy</h2>
|
||||||
<form method="post" action="/settings" class="space-y-4">
|
<form method="post" action="/settings" class="space-y-4">
|
||||||
@CSRFInput(d.CSRFToken)
|
@CSRFInput(d.CSRFToken)
|
||||||
<div>
|
<div>
|
||||||
<label class="v-label">Apify API Key</label>
|
<label class="v-label">Apify API Key</label>
|
||||||
<input class="v-input font-mono" type="password" name="apify_api_key" value={ d.Values["apify_api_key"] }/>
|
<input class="v-input font-mono" type="password" name="apify_api_key" value={ d.Values["apify_api_key"] }/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="border-t border-white/10 pt-4">
|
||||||
|
<label class="v-label">eBay App ID (Client ID)</label>
|
||||||
|
<input class="v-input font-mono" type="password" name="ebay_client_id" value={ d.Values["ebay_client_id"] } placeholder="used for eBay marketplaces instead of Apify"/>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<label class="v-label">eBay Cert ID (Client Secret)</label>
|
||||||
|
<input class="v-input font-mono" type="password" name="ebay_client_secret" value={ d.Values["ebay_client_secret"] }/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="v-label">eBay Daily Call Limit</label>
|
||||||
|
<input class="v-input font-mono" name="ebay_daily_call_limit" type="number" value={ d.Values["ebay_daily_call_limit"] } placeholder="5000 (blank uses config default)"/>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm">
|
||||||
|
<span class="v-muted">eBay API calls today:</span>
|
||||||
|
if d.EbayDailyLimit > 0 {
|
||||||
|
<span class="font-mono">{ fmt.Sprintf("%d / %d", d.EbayUsedToday, d.EbayDailyLimit) }</span>
|
||||||
|
} else {
|
||||||
|
<span class="font-mono">{ fmt.Sprintf("%d (uncapped)", d.EbayUsedToday) }</span>
|
||||||
|
}
|
||||||
|
if d.EbayLimitReached() {
|
||||||
|
<span class="v-flash-error inline-block ml-2">Limit reached. eBay polling halted until the next reset (midnight US Pacific).</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-white/10 pt-4">
|
||||||
<label class="v-label">Ntfy Base URL</label>
|
<label class="v-label">Ntfy Base URL</label>
|
||||||
<input class="v-input" name="ntfy_base_url" value={ d.Values["ntfy_base_url"] }/>
|
<input class="v-input" name="ntfy_base_url" value={ d.Values["ntfy_base_url"] }/>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,6 +90,7 @@ templ settingsBody(d SettingsData) {
|
|||||||
<button class="v-btn" type="submit">Save</button>
|
<button class="v-btn" type="submit">Save</button>
|
||||||
<button class="v-btn-ghost" type="submit" formaction="/settings/test-ntfy">Test Ntfy</button>
|
<button class="v-btn-ghost" type="submit" formaction="/settings/test-ntfy">Test Ntfy</button>
|
||||||
<button class="v-btn-ghost" type="submit" formaction="/settings/test-apify">Test Apify</button>
|
<button class="v-btn-ghost" type="submit" formaction="/settings/test-apify">Test Apify</button>
|
||||||
|
<button class="v-btn-ghost" type="submit" formaction="/settings/test-ebay">Test eBay</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</form>
|
</form>
|
||||||
@@ -67,6 +100,9 @@ templ settingsBody(d SettingsData) {
|
|||||||
if d.TestApifyOK != "" {
|
if d.TestApifyOK != "" {
|
||||||
<div class="v-flash mt-3">{ d.TestApifyOK }</div>
|
<div class="v-flash mt-3">{ d.TestApifyOK }</div>
|
||||||
}
|
}
|
||||||
|
if d.TestEbayOK != "" {
|
||||||
|
<div class="v-flash mt-3">{ d.TestEbayOK }</div>
|
||||||
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="v-card p-6">
|
<section class="v-card p-6">
|
||||||
|
|||||||
@@ -16,15 +16,24 @@ import (
|
|||||||
|
|
||||||
type SettingsData struct {
|
type SettingsData struct {
|
||||||
Page
|
Page
|
||||||
Values map[string]string
|
Values map[string]string
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
Users []models.User
|
Users []models.User
|
||||||
TestNtfyOK string
|
TestNtfyOK string
|
||||||
TestApifyOK string
|
TestApifyOK string
|
||||||
PasswordMsg string
|
TestEbayOK string
|
||||||
PasswordError string
|
EbayUsedToday int
|
||||||
UserMsg string
|
EbayDailyLimit int
|
||||||
UserError string
|
PasswordMsg string
|
||||||
|
PasswordError string
|
||||||
|
UserMsg string
|
||||||
|
UserError string
|
||||||
|
}
|
||||||
|
|
||||||
|
// EbayLimitReached reports whether eBay polling is currently halted because
|
||||||
|
// the daily call limit has been hit.
|
||||||
|
func (d SettingsData) EbayLimitReached() bool {
|
||||||
|
return d.EbayDailyLimit > 0 && d.EbayUsedToday >= d.EbayDailyLimit
|
||||||
}
|
}
|
||||||
|
|
||||||
func settingsBody(d SettingsData) templ.Component {
|
func settingsBody(d SettingsData) templ.Component {
|
||||||
@@ -48,7 +57,7 @@ func settingsBody(d SettingsData) templ.Component {
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"space-y-8 max-w-3xl\"><h1 class=\"text-3xl font-semibold\">Settings</h1><section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Apify and Ntfy</h2><form method=\"post\" action=\"/settings\" class=\"space-y-4\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"space-y-8 max-w-3xl\"><h1 class=\"text-3xl font-semibold\">Settings</h1><section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Apify, eBay and Ntfy</h2><form method=\"post\" action=\"/settings\" class=\"space-y-4\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -63,177 +72,282 @@ func settingsBody(d SettingsData) templ.Component {
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["apify_api_key"])
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["apify_api_key"])
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 32, Col: 108}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 41, Col: 108}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"></div><div><label class=\"v-label\">Ntfy Base URL</label> <input class=\"v-input\" name=\"ntfy_base_url\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"></div><div class=\"border-t border-white/10 pt-4\"><label class=\"v-label\">eBay App ID (Client ID)</label> <input class=\"v-input font-mono\" type=\"password\" name=\"ebay_client_id\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_base_url"])
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ebay_client_id"])
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 36, Col: 82}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 45, Col: 110}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\"></div><div><label class=\"v-label\">Ntfy Default Topic</label> <input class=\"v-input\" name=\"ntfy_default_topic\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" placeholder=\"used for eBay marketplaces instead of Apify\"></div><div><label class=\"v-label\">eBay Cert ID (Client Secret)</label> <input class=\"v-input font-mono\" type=\"password\" name=\"ebay_client_secret\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_default_topic"])
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ebay_client_secret"])
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 40, Col: 92}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 49, Col: 118}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\"></div><div><label class=\"v-label\">Ntfy Token</label> <input class=\"v-input font-mono\" type=\"password\" name=\"ntfy_token\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\"></div><div><label class=\"v-label\">eBay Daily Call Limit</label> <input class=\"v-input font-mono\" name=\"ebay_daily_call_limit\" type=\"number\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_token"])
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ebay_daily_call_limit"])
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 44, Col: 102}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 53, Col: 122}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" placeholder=\"tk_... (leave blank if ntfy is unauthenticated)\"></div><div><label class=\"v-label\">Global Poll Interval (minutes)</label> <input class=\"v-input font-mono\" name=\"global_poll_interval_minutes\" type=\"number\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" placeholder=\"5000 (blank uses config default)\"></div><div class=\"text-sm\"><span class=\"v-muted\">eBay API calls today:</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var6 string
|
if d.EbayDailyLimit > 0 {
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["global_poll_interval_minutes"])
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<span class=\"font-mono\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 48, Col: 136}
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
var templ_7745c5c3_Var6 string
|
||||||
if templ_7745c5c3_Err != nil {
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d", d.EbayUsedToday, d.EbayDailyLimit))
|
||||||
return templ_7745c5c3_Err
|
if templ_7745c5c3_Err != nil {
|
||||||
}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 58, Col: 89}
|
||||||
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 {
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
return templ_7745c5c3_Err
|
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"])
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span> ")
|
||||||
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"flex items-center gap-3 pt-1\"><button class=\"v-btn\" type=\"submit\">Save</button> <button class=\"v-btn-ghost\" type=\"submit\" formaction=\"/settings/test-ntfy\">Test Ntfy</button> <button class=\"v-btn-ghost\" type=\"submit\" formaction=\"/settings/test-apify\">Test Apify</button></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<span class=\"font-mono\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var7 string
|
||||||
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d (uncapped)", d.EbayUsedToday))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 60, Col: 77}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</form>")
|
if d.EbayLimitReached() {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<span class=\"v-flash-error inline-block ml-2\">Limit reached. eBay polling halted until the next reset (midnight US Pacific).</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div><div class=\"border-t border-white/10 pt-4\"><label class=\"v-label\">Ntfy Base URL</label> <input class=\"v-input\" name=\"ntfy_base_url\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var8 string
|
||||||
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_base_url"])
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 68, Col: 82}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"></div><div><label class=\"v-label\">Ntfy Default Topic</label> <input class=\"v-input\" name=\"ntfy_default_topic\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var9 string
|
||||||
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_default_topic"])
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 72, Col: 92}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\"></div><div><label class=\"v-label\">Ntfy Token</label> <input class=\"v-input font-mono\" type=\"password\" name=\"ntfy_token\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var10 string
|
||||||
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_token"])
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 76, Col: 102}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" placeholder=\"tk_... (leave blank if ntfy is unauthenticated)\"></div><div><label class=\"v-label\">Global Poll Interval (minutes)</label> <input class=\"v-input font-mono\" name=\"global_poll_interval_minutes\" type=\"number\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var11 string
|
||||||
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["global_poll_interval_minutes"])
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 80, Col: 136}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\"></div><div><label class=\"v-label\">Match Confidence Threshold</label> <input class=\"v-input font-mono\" name=\"match_confidence_threshold\" type=\"number\" min=\"0\" max=\"1\" step=\"0.05\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var12 string
|
||||||
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["match_confidence_threshold"])
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 84, Col: 160}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\"></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if !d.IsAdmin {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"v-muted text-sm\">Read-only for non-admin users.</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"flex items-center gap-3 pt-1\"><button class=\"v-btn\" type=\"submit\">Save</button> <button class=\"v-btn-ghost\" type=\"submit\" formaction=\"/settings/test-ntfy\">Test Ntfy</button> <button class=\"v-btn-ghost\" type=\"submit\" formaction=\"/settings/test-apify\">Test Apify</button> <button class=\"v-btn-ghost\" type=\"submit\" formaction=\"/settings/test-ebay\">Test eBay</button></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</form>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if d.TestNtfyOK != "" {
|
if d.TestNtfyOK != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"v-flash mt-3\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<div class=\"v-flash mt-3\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestNtfyOK)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestNtfyOK)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 65, Col: 44}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 98, Col: 44}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if d.TestApifyOK != "" {
|
if d.TestApifyOK != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"v-flash mt-3\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"v-flash mt-3\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestApifyOK)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestApifyOK)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 68, Col: 45}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 101, Col: 45}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</section><section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Change Password</h2>")
|
if d.TestEbayOK != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"v-flash mt-3\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var15 string
|
||||||
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestEbayOK)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 104, Col: 44}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</section><section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Change Password</h2>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if d.PasswordError != "" {
|
if d.PasswordError != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"v-flash-error\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<div class=\"v-flash-error\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordError)
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordError)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 75, Col: 48}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 111, Col: 48}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if d.PasswordMsg != "" {
|
if d.PasswordMsg != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"v-flash\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<div class=\"v-flash\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordMsg)
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordMsg)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 78, Col: 40}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 114, Col: 40}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<form method=\"post\" action=\"/settings/password\" class=\"space-y-4\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<form method=\"post\" action=\"/settings/password\" class=\"space-y-4\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -241,173 +355,173 @@ func settingsBody(d SettingsData) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<div><label class=\"v-label\">Current Password</label> <input class=\"v-input\" type=\"password\" name=\"current_password\"></div><div><label class=\"v-label\">New Password</label> <input class=\"v-input\" type=\"password\" name=\"new_password\"></div><div><label class=\"v-label\">Confirm New Password</label> <input class=\"v-input\" type=\"password\" name=\"new_password_confirm\"></div><button class=\"v-btn\" type=\"submit\">Update Password</button></form></section>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div><label class=\"v-label\">Current Password</label> <input class=\"v-input\" type=\"password\" name=\"current_password\"></div><div><label class=\"v-label\">New Password</label> <input class=\"v-input\" type=\"password\" name=\"new_password\"></div><div><label class=\"v-label\">Confirm New Password</label> <input class=\"v-input\" type=\"password\" name=\"new_password_confirm\"></div><button class=\"v-btn\" type=\"submit\">Update Password</button></form></section>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if d.IsAdmin {
|
if d.IsAdmin {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Users</h2>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Users</h2>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if d.UserError != "" {
|
if d.UserError != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"v-flash-error\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<div class=\"v-flash-error\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var18 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserError)
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserError)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 102, Col: 45}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 138, Col: 45}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if d.UserMsg != "" {
|
if d.UserMsg != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<div class=\"v-flash\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<div class=\"v-flash\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserMsg)
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserMsg)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 105, Col: 37}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 141, Col: 37}
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" onsubmit=\"return confirm('Remove user?')\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<table class=\"v-table mb-4\"><thead><tr><th>Username</th><th>Role</th><th>Created</th><th></th></tr></thead> <tbody>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, u := range d.Users {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<tr><td>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var20 string
|
var templ_7745c5c3_Var20 string
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 122, Col: 68}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 148, Col: 24}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\"> <button class=\"v-btn-ghost\" type=\"submit\">Remove</button></form></td></tr>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</td><td class=\"v-muted\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var21 string
|
||||||
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(string(u.Role))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 149, Col: 44}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</td><td class=\"v-muted text-sm\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var22 string
|
||||||
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(u.CreatedAt.Format("2006-01-02"))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 150, Col: 70}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</td><td class=\"text-right\"><form class=\"inline\" method=\"post\" action=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var23 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/users/%d/reset-password", u.ID)))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 152, Col: 113}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var24 string
|
||||||
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 153, Col: 68}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\"> <input type=\"password\" class=\"v-input inline-block max-w-[140px]\" name=\"new_password\" placeholder=\"new password\"> <button class=\"v-btn-ghost\" type=\"submit\">Reset</button></form><form class=\"inline\" method=\"post\" action=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var25 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/users/%d/delete", u.ID)))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 157, Col: 105}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" onsubmit=\"return confirm('Remove user?')\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var26 string
|
||||||
|
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 158, Col: 68}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\"> <button class=\"v-btn-ghost\" type=\"submit\">Remove</button></form></td></tr>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</tbody></table><form method=\"post\" action=\"/users\" class=\"grid md:grid-cols-4 gap-3 items-end\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</tbody></table><form method=\"post\" action=\"/users\" class=\"grid md:grid-cols-4 gap-3 items-end\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var21 string
|
var templ_7745c5c3_Var27 string
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
|
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 131, Col: 63}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 167, Col: 63}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\"><div><label class=\"v-label\">Username</label> <input class=\"v-input\" name=\"username\"></div><div><label class=\"v-label\">Role</label> <select class=\"v-select\" name=\"role\"><option value=\"user\">user</option> <option value=\"admin\">admin</option></select></div><div><label class=\"v-label\">Initial Password</label> <input class=\"v-input\" type=\"password\" name=\"password\"></div><button class=\"v-btn\" type=\"submit\">Add User</button></form></section>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\"><div><label class=\"v-label\">Username</label> <input class=\"v-input\" name=\"username\"></div><div><label class=\"v-label\">Role</label> <select class=\"v-select\" name=\"role\"><option value=\"user\">user</option> <option value=\"admin\">admin</option></select></div><div><label class=\"v-label\">Initial Password</label> <input class=\"v-input\" type=\"password\" name=\"password\"></div><button class=\"v-btn\" type=\"submit\">Add User</button></form></section>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -431,9 +545,9 @@ func Settings(d SettingsData) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var22 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var28 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var22 == nil {
|
if templ_7745c5c3_Var28 == nil {
|
||||||
templ_7745c5c3_Var22 = templ.NopComponent
|
templ_7745c5c3_Var28 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = Layout(d.Page, settingsBody(d)).Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = Layout(d.Page, settingsBody(d)).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
|||||||
Reference in New Issue
Block a user