Initial commit

This commit is contained in:
2026-05-13 19:42:49 -07:00
commit cfa01bd4ef
54 changed files with 11718 additions and 0 deletions

302
templates/item_form.templ Normal file
View File

@@ -0,0 +1,302 @@
package templates
import (
"fmt"
"veola/internal/models"
)
type ItemFormData struct {
Page
IsEdit bool
Item models.Item
Categories []string
Errors []string
}
func itemSelected(have, want string) bool { return have == want }
type marketplaceOpt struct {
Value string
Label string
}
func marketplaceOptions() []marketplaceOpt {
return []marketplaceOpt{
{"ebay.com", "eBay (US)"},
{"ebay.co.uk", "eBay (UK)"},
{"ebay.de", "eBay (DE)"},
{"ebay.fr", "eBay (FR)"},
{"ebay.com.au", "eBay (AU)"},
{"ebay.ca", "eBay (CA)"},
{"yahoo.co.jp", "Yahoo Auctions JP"},
{"mercari.jp", "Mercari JP"},
}
}
func isKnownMarketplace(v string) bool {
for _, o := range marketplaceOptions() {
if o.Value == v {
return true
}
}
return false
}
func itemHasMarketplace(it models.Item, v string) bool {
for _, m := range it.Marketplaces {
if m == v {
return true
}
}
return false
}
// customMarketplacesCSV returns the comma-separated list of marketplaces on
// the item that are NOT in the curated list, so the user can keep editing
// unusual values without losing them.
func customMarketplacesCSV(it models.Item) string {
var custom []string
for _, m := range it.Marketplaces {
if !isKnownMarketplace(m) {
custom = append(custom, m)
}
}
return joinCSV(custom)
}
func joinCSV(vs []string) string {
out := ""
for i, v := range vs {
if i > 0 {
out += ", "
}
out += v
}
return out
}
func itemHasJapanMarketplace(it models.Item) bool {
for _, m := range it.Marketplaces {
if m == "yahoo.co.jp" || m == "mercari.jp" {
return true
}
}
return false
}
func newCategory(v string, known []string) string {
if v == "" {
return ""
}
for _, c := range known {
if c == v {
return ""
}
}
return v
}
templ itemFormBody(d ItemFormData) {
<div>
<h1 class="text-3xl font-semibold mb-6">
if d.IsEdit {
Edit { d.Item.Name }
} else {
Add Item
}
</h1>
if len(d.Errors) > 0 {
<div class="v-flash-error">
<ul class="list-disc pl-5">
for _, e := range d.Errors {
<li>{ e }</li>
}
</ul>
</div>
}
@itemFormInner(d)
</div>
}
templ itemFormInner(d ItemFormData) {
<form
method="post"
action={ formAction(d) }
if !d.IsEdit {
hx-post="/items/preview"
hx-target="#preview-target"
hx-swap="innerHTML"
hx-indicator="#preview-loading"
hx-disabled-elt="find button[type='submit']"
}
class="v-card p-6 space-y-4 max-w-3xl"
>
@CSRFInput(d.CSRFToken)
<div class="grid md:grid-cols-2 gap-4">
<div>
<label class="v-label">Name</label>
<input class="v-input" name="name" value={ d.Item.Name } required/>
</div>
<div>
<label class="v-label">Category</label>
<select class="v-select" name="category">
<option value="" selected?={ d.Item.Category == "" }>— none —</option>
for _, c := range d.Categories {
<option value={ c } selected?={ d.Item.Category == c }>{ c }</option>
}
</select>
<input class="v-input mt-2 text-sm" name="category_new" value={ newCategory(d.Item.Category, d.Categories) } placeholder="New category (overrides above)"/>
</div>
</div>
<div>
<label class="v-label">Search Queries</label>
<textarea class="v-textarea" name="search_query" rows="4" lang="ja" placeholder="One per line, or comma / semicolon separated">{ d.Item.SearchQuery }</textarea>
<div class="v-muted text-xs mt-1">Each is searched independently; the lowest price across all wins. Cost scales with the number of queries.</div>
</div>
<div>
<label class="v-label">Product URL</label>
<input class="v-input" name="url" value={ d.Item.URL }/>
<div class="v-muted text-xs mt-1">At least one of search queries or URL is required.</div>
</div>
<div class="grid md:grid-cols-3 gap-4">
<div>
<label class="v-label">Target Price</label>
<input class="v-input font-mono" name="target_price" type="number" step="0.01" value={ optFloat(d.Item.TargetPrice) }/>
<div class="v-muted text-xs mt-1">Blank = alert on every new result.</div>
</div>
<div>
<label class="v-label">Ntfy Topic</label>
<input class="v-input" name="ntfy_topic" value={ d.Item.NtfyTopic } required/>
</div>
<div>
<label class="v-label">Ntfy Priority</label>
<select class="v-select" name="ntfy_priority">
for _, p := range []string{"min","low","default","high","urgent"} {
<option value={ p } selected?={ itemSelected(d.Item.NtfyPriority, p) }>{ p }</option>
}
</select>
</div>
</div>
<div class="grid md:grid-cols-3 gap-4">
<div>
<label class="v-label">Poll Interval</label>
<select class="v-select" name="poll_interval_minutes">
for _, opt := range pollOptions() {
<option value={ fmt.Sprintf("%d", opt.Minutes) } selected?={ d.Item.PollIntervalMinutes == opt.Minutes }>{ opt.Label }</option>
}
</select>
</div>
<div>
<label class="v-label">Marketplaces</label>
<div id="marketplace-grid" class="grid grid-cols-2 gap-1" onchange="document.getElementById('marketplace-jp-note').hidden = !document.querySelector('#marketplace-grid input[value=&quot;yahoo.co.jp&quot;]:checked, #marketplace-grid input[value=&quot;mercari.jp&quot;]:checked')">
for _, m := range marketplaceOptions() {
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" name="marketplace" value={ m.Value } checked?={ itemHasMarketplace(d.Item, m.Value) }/>
<span>{ m.Label }</span>
</label>
}
</div>
<input class="v-input mt-2 text-sm" name="marketplace_custom" value={ customMarketplacesCSV(d.Item) } placeholder="Custom (comma-separated, added to above)"/>
<div
id="marketplace-jp-note"
class="v-flash mt-2 text-sm"
hidden?={ !itemHasJapanMarketplace(d.Item) }
>Yahoo Auctions JP and Mercari JP search in Japanese. English queries return few or no results.</div>
<div class="v-muted text-xs mt-1">Pick one or more. Each is polled per cycle; one failure does not stop the others.</div>
</div>
<div>
<label class="v-label">Listing Type</label>
<select class="v-select" name="listing_type">
for _, lt := range []string{"all","BIN","auction"} {
<option value={ lt } selected?={ itemSelected(d.Item.ListingType, lt) }>{ lt }</option>
}
</select>
</div>
</div>
<div class="grid md:grid-cols-2 gap-4">
<div>
<label class="v-label">Minimum Price</label>
<input class="v-input font-mono" name="min_price" type="number" step="0.01" value={ optFloat(d.Item.MinPrice) }/>
<div class="v-muted text-xs mt-1">Drop results below this price. Filters out accessories and obvious junk.</div>
</div>
<div>
<label class="v-label">Exclude Keywords</label>
<textarea class="v-textarea" name="exclude_keywords" rows="3" placeholder="One per line, or comma separated">{ d.Item.ExcludeKeywords }</textarea>
<div class="v-muted text-xs mt-1">Drop results whose title contains any of these. Case-insensitive substring match.</div>
</div>
</div>
<label class="flex items-center gap-2">
<input type="checkbox" name="include_out_of_stock" checked?={ d.Item.IncludeOutOfStock } value="1"/>
<span>Include out-of-stock results</span>
</label>
<details class="v-card-flat p-4">
<summary class="cursor-pointer font-semibold">Advanced</summary>
<div class="v-muted text-sm mt-2">Leave blank to use the configured default for the selected marketplace.</div>
<div class="grid md:grid-cols-2 gap-4 mt-3">
<div>
<label class="v-label">Active Listings Actor</label>
<input class="v-input" name="actor_active" value={ d.Item.ActorActive } placeholder="from config"/>
</div>
<div>
<label class="v-label">Sold Listings Actor</label>
<input class="v-input" name="actor_sold" value={ d.Item.ActorSold } placeholder="from config"/>
</div>
<div>
<label class="v-label">Price Comparison Actor</label>
<input class="v-input" name="actor_price_compare" value={ d.Item.ActorPriceCompare } placeholder="from config"/>
</div>
<label class="flex items-center gap-2 mt-6">
<input type="checkbox" name="use_price_comparison" checked?={ d.Item.UsePriceComparison } value="1"/>
<span>Use price comparison actor in addition to active listings</span>
</label>
</div>
</details>
<div class="flex items-center gap-3 pt-2">
if d.IsEdit {
<button class="v-btn" type="submit">Save</button>
<a class="v-btn-ghost" href="/items">Cancel</a>
} else {
<button class="v-btn" type="submit">Preview</button>
<a class="v-btn-ghost" href="/items">Cancel</a>
<span id="preview-loading" class="htmx-indicator v-muted text-sm flex items-center gap-2">
<span class="v-spinner"></span>
Running preview… apify runs can take 3060s.
</span>
}
</div>
</form>
if !d.IsEdit {
<div id="preview-target" class="mt-6"></div>
}
}
func formAction(d ItemFormData) templ.SafeURL {
if d.IsEdit {
return templ.SafeURL(fmt.Sprintf("/items/%d", d.Item.ID))
}
return templ.SafeURL("/items/preview")
}
type pollOpt struct {
Minutes int
Label string
}
func pollOptions() []pollOpt {
return []pollOpt{
{15, "15 min"}, {30, "30 min"}, {60, "1 hr"}, {120, "2 hr"},
{360, "6 hr"}, {720, "12 hr"}, {1440, "24 hr"},
}
}
func optFloat(p *float64) string {
if p == nil {
return ""
}
return fmt.Sprintf("%.2f", *p)
}
templ ItemForm(d ItemFormData) {
@Layout(d.Page, itemFormBody(d))
}