- Replace events+expenses with flat purchases table - Add warranty_months, return_days, product_name fields - Remove currency conversion, CSV/PDF reporting, event filing - Simplify auth (no onboarding/department/profile) - Update AI extraction prompts for product/warranty info - Update all branding: templates, install.sh, Makefile, service file
669 lines
23 KiB
Go
669 lines
23 KiB
Go
// Package handlers provides HTTP request handlers for NextReceipt.
|
|
//
|
|
// This file implements purchase upload, AI extraction, and save handlers
|
|
// using HTMX partial responses.
|
|
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"fmt"
|
|
"html/template"
|
|
"image"
|
|
"image/jpeg"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"golang.org/x/image/draw"
|
|
|
|
"github.com/cclohmar/NextReceipt/internal/ai"
|
|
"github.com/cclohmar/NextReceipt/internal/database"
|
|
"github.com/cclohmar/NextReceipt/internal/utils"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PurchaseHandler
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// PurchaseHandler groups HTTP handlers related to purchase/receipt management.
|
|
type PurchaseHandler struct {
|
|
DB *sql.DB
|
|
}
|
|
|
|
// NewPurchaseHandler creates a new PurchaseHandler with the given database handle.
|
|
func NewPurchaseHandler(db *sql.DB) *PurchaseHandler {
|
|
return &PurchaseHandler{DB: db}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// POST /purchases/upload — UploadReceipt
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// UploadReceipt handles receipt image upload, AI extraction, and returns
|
|
// an HTMX fragment with a pre-filled receipt edit form.
|
|
func (h *PurchaseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) {
|
|
// 1. Parse multipart form with 10 MB max memory.
|
|
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
|
log.Printf("ERROR [%s] handlers: UploadReceipt: parse form: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
renderUploadError(w, "Failed to parse upload form.")
|
|
return
|
|
}
|
|
defer r.MultipartForm.RemoveAll()
|
|
|
|
// 2. Get the file from the "receipt" form field.
|
|
file, header, err := r.FormFile("receipt")
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] handlers: UploadReceipt: missing receipt field: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
renderUploadError(w, "Missing receipt file.")
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
// 3. Validate file size (max 10 MB).
|
|
if header.Size > 10<<20 {
|
|
log.Printf("ERROR [%s] handlers: UploadReceipt: file too large: %d bytes",
|
|
time.Now().Format(time.RFC3339), header.Size)
|
|
renderUploadError(w, "File too large. Maximum size is 10 MB.")
|
|
return
|
|
}
|
|
|
|
// 4. Read the full file data.
|
|
fileData, err := io.ReadAll(file)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] handlers: UploadReceipt: read file: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
renderUploadError(w, "Failed to read uploaded file.")
|
|
return
|
|
}
|
|
|
|
// 5. Validate content type by inspecting magic bytes.
|
|
ext := detectImageExtension(fileData)
|
|
if ext == "" {
|
|
log.Printf("ERROR [%s] handlers: UploadReceipt: unsupported file type")
|
|
renderUploadError(w, "Unsupported file format. Please upload a receipt image (JPEG, PNG, HEIC) or PDF.")
|
|
return
|
|
}
|
|
|
|
// Resize the image (max 2048px, JPEG 85%).
|
|
resized, resizeErr := resizeImage(fileData)
|
|
if resizeErr == nil && len(resized) > 0 {
|
|
fileData = resized
|
|
if ext != "jpg" && ext != "jpeg" {
|
|
ext = "jpg"
|
|
}
|
|
}
|
|
|
|
// 6. Generate a UUID-based filename and ensure the storage directory exists.
|
|
filename := utils.NewUUID() + "." + ext
|
|
storagePath := filepath.Join("storage", filename)
|
|
|
|
if err := os.MkdirAll("storage", 0755); err != nil {
|
|
log.Printf("ERROR [%s] handlers: UploadReceipt: mkdir storage: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
renderUploadError(w, "Server error. Please try again.")
|
|
return
|
|
}
|
|
|
|
// 7. Save the image file to disk.
|
|
if err := os.WriteFile(storagePath, fileData, 0644); err != nil {
|
|
log.Printf("ERROR [%s] handlers: UploadReceipt: write file: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
renderUploadError(w, "Failed to save receipt image. Please try again.")
|
|
return
|
|
}
|
|
|
|
// 8. Call the AI API for receipt data extraction.
|
|
receipt, aiErr := ai.ExtractReceipt(filepath.Join("storage", filename))
|
|
|
|
// Strip the storage/ prefix so the template can build a proper URL: /storage/{file}
|
|
storagePath = filename
|
|
|
|
// 9. Render the receipt_form.html fragment.
|
|
tmpl := getTemplate("receipt_form.html")
|
|
|
|
data := map[string]interface{}{
|
|
"ImagePath": storagePath,
|
|
"AIError": "",
|
|
"ProductName": "",
|
|
"Store": "",
|
|
"Category": "",
|
|
"Amount": "",
|
|
"Currency": "",
|
|
"Date": "",
|
|
"WarrantyMonths": "0",
|
|
"ReturnDays": "0",
|
|
"Notes": "",
|
|
}
|
|
|
|
if aiErr != nil {
|
|
data["AIError"] = "Could not read receipt automatically. Please fill in the fields below."
|
|
log.Printf("ERROR [%s] handlers: UploadReceipt: AI extraction failed: %v",
|
|
time.Now().Format(time.RFC3339), aiErr)
|
|
} else if receipt != nil {
|
|
data["ProductName"] = receipt.ProductName
|
|
data["Store"] = receipt.Merchant
|
|
data["Category"] = receipt.Category
|
|
data["Amount"] = strconv.FormatFloat(receipt.Amount, 'f', 2, 64)
|
|
data["Currency"] = receipt.Currency
|
|
data["Date"] = receipt.Date
|
|
if receipt.WarrantyMonths > 0 {
|
|
data["WarrantyMonths"] = strconv.Itoa(receipt.WarrantyMonths)
|
|
}
|
|
if receipt.ReturnDays > 0 {
|
|
data["ReturnDays"] = strconv.Itoa(receipt.ReturnDays)
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := tmpl.Execute(w, data); err != nil {
|
|
log.Printf("ERROR [%s] handlers: UploadReceipt: template execute: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// POST /purchases — SavePurchase
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// SavePurchase handles the receipt form submission, saves the purchase to the
|
|
// database, and returns an HTMX multi-target response.
|
|
func (h *PurchaseHandler) SavePurchase(w http.ResponseWriter, r *http.Request) {
|
|
userID := getUserID(r)
|
|
if userID == "" {
|
|
http.Error(w, "Session expired. Please log in again.", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// 1. Parse form fields.
|
|
if err := r.ParseForm(); err != nil {
|
|
log.Printf("ERROR [%s] handlers: SavePurchase: parse form: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
http.Error(w, "Cannot parse form data", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
productName := strings.TrimSpace(r.FormValue("product_name"))
|
|
store := strings.TrimSpace(r.FormValue("store"))
|
|
category := strings.TrimSpace(r.FormValue("category"))
|
|
amountStr := strings.TrimSpace(r.FormValue("amount"))
|
|
currency := strings.TrimSpace(r.FormValue("currency"))
|
|
date := strings.TrimSpace(r.FormValue("date"))
|
|
notes := strings.TrimSpace(r.FormValue("notes"))
|
|
imagePath := strings.TrimSpace(r.FormValue("image_path"))
|
|
warrantyStr := strings.TrimSpace(r.FormValue("warranty_months"))
|
|
returnStr := strings.TrimSpace(r.FormValue("return_days"))
|
|
|
|
// 2. Validate required fields.
|
|
var missing []string
|
|
if productName == "" {
|
|
missing = append(missing, "product name")
|
|
}
|
|
if store == "" {
|
|
missing = append(missing, "store")
|
|
}
|
|
if category == "" {
|
|
missing = append(missing, "category")
|
|
}
|
|
if amountStr == "" {
|
|
missing = append(missing, "amount")
|
|
}
|
|
if currency == "" {
|
|
missing = append(missing, "currency")
|
|
}
|
|
if date == "" {
|
|
missing = append(missing, "purchase date")
|
|
}
|
|
if len(missing) > 0 {
|
|
http.Error(w, "Missing required fields: "+strings.Join(missing, ", "),
|
|
http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// 3. Parse numeric fields.
|
|
amount, err := strconv.ParseFloat(amountStr, 64)
|
|
if err != nil {
|
|
http.Error(w, "Invalid amount value", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
warrantyMonths := 0
|
|
if warrantyStr != "" {
|
|
warrantyMonths, _ = strconv.Atoi(warrantyStr)
|
|
}
|
|
|
|
returnDays := 0
|
|
if returnStr != "" {
|
|
returnDays, _ = strconv.Atoi(returnStr)
|
|
}
|
|
|
|
// 4. Build and save the purchase record.
|
|
purchase := database.Purchase{
|
|
ID: utils.NewUUID(),
|
|
UserID: userID,
|
|
ProductName: productName,
|
|
Store: store,
|
|
Category: category,
|
|
Amount: amount,
|
|
Currency: currency,
|
|
PurchaseDate: date,
|
|
WarrantyMonths: warrantyMonths,
|
|
ReturnDays: returnDays,
|
|
Notes: notes,
|
|
ImagePath: imagePath,
|
|
}
|
|
|
|
if err := database.CreatePurchase(h.DB, purchase); err != nil {
|
|
log.Printf("ERROR [%s] handlers: SavePurchase: create purchase: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
http.Error(w, "Failed to save purchase", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Mark user as onboarded on first purchase.
|
|
database.MarkUserOnboarded(h.DB, userID)
|
|
|
|
// 5. Fetch the updated purchase list.
|
|
purchases, err := database.GetPurchasesByUser(h.DB, userID)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] handlers: SavePurchase: fetch purchases: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
http.Error(w, "Failed to retrieve purchases", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Normalize ImagePath.
|
|
for i := range purchases {
|
|
purchases[i].ImagePath = normalizeImagePath(purchases[i].ImagePath)
|
|
}
|
|
|
|
// 6. Render the purchase list fragment.
|
|
listBuf := renderPurchaseList(purchases)
|
|
|
|
// 7. Return HTMX multi-target response.
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("HX-Refresh", "false")
|
|
fmt.Fprintf(w, `<div id="receipt-form" hx-swap-oob="true"><div style="background: #064e3b; border: 1px solid #065f46; color: #6ee7b7; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">Purchase saved!</div></div>`)
|
|
fmt.Fprintf(w, `<div id="purchase-list" hx-swap-oob="true">%s</div>`, listBuf)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GET /purchases/{id}/edit — EditPurchase
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// EditPurchase returns the receipt form pre-filled with an existing purchase's data.
|
|
func (h *PurchaseHandler) EditPurchase(w http.ResponseWriter, r *http.Request) {
|
|
purchaseID := chi.URLParam(r, "id")
|
|
if purchaseID == "" {
|
|
http.Error(w, "Missing purchase ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
purchase, err := database.GetPurchaseByID(h.DB, purchaseID)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] handlers: EditPurchase: GetPurchaseByID(%s): %v",
|
|
time.Now().Format(time.RFC3339), purchaseID, err)
|
|
http.Error(w, "Failed to retrieve purchase", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if purchase == nil {
|
|
http.Error(w, "Purchase not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Verify ownership.
|
|
if purchase.UserID != getUserID(r) {
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
tmpl := getTemplate("receipt_form.html")
|
|
|
|
data := map[string]interface{}{
|
|
"ImagePath": normalizeImagePath(purchase.ImagePath),
|
|
"AIError": "",
|
|
"ProductName": purchase.ProductName,
|
|
"Store": purchase.Store,
|
|
"Category": purchase.Category,
|
|
"Amount": strconv.FormatFloat(purchase.Amount, 'f', 2, 64),
|
|
"Currency": purchase.Currency,
|
|
"Date": purchase.PurchaseDate,
|
|
"WarrantyMonths": strconv.Itoa(purchase.WarrantyMonths),
|
|
"ReturnDays": strconv.Itoa(purchase.ReturnDays),
|
|
"Notes": purchase.Notes,
|
|
"EditID": purchase.ID,
|
|
"ID": purchase.ID,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := tmpl.Execute(w, data); err != nil {
|
|
log.Printf("ERROR [%s] handlers: EditPurchase: execute template: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PUT /purchases/{id} — UpdatePurchase
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// UpdatePurchase updates an existing purchase record with form data.
|
|
func (h *PurchaseHandler) UpdatePurchase(w http.ResponseWriter, r *http.Request) {
|
|
purchaseID := chi.URLParam(r, "id")
|
|
if purchaseID == "" {
|
|
http.Error(w, "Missing purchase ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
log.Printf("ERROR [%s] handlers: UpdatePurchase: parse form: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
http.Error(w, "Cannot parse form data", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
existing, err := database.GetPurchaseByID(h.DB, purchaseID)
|
|
if err != nil || existing == nil {
|
|
log.Printf("ERROR [%s] handlers: UpdatePurchase: get existing: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
http.Error(w, "Purchase not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if existing.UserID != getUserID(r) {
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
amount, _ := strconv.ParseFloat(r.FormValue("amount"), 64)
|
|
warrantyMonths, _ := strconv.Atoi(r.FormValue("warranty_months"))
|
|
returnDays, _ := strconv.Atoi(r.FormValue("return_days"))
|
|
|
|
purchase := database.Purchase{
|
|
ID: purchaseID,
|
|
ProductName: strings.TrimSpace(r.FormValue("product_name")),
|
|
Store: strings.TrimSpace(r.FormValue("store")),
|
|
Category: strings.TrimSpace(r.FormValue("category")),
|
|
Amount: amount,
|
|
Currency: strings.TrimSpace(r.FormValue("currency")),
|
|
PurchaseDate: strings.TrimSpace(r.FormValue("date")),
|
|
WarrantyMonths: warrantyMonths,
|
|
ReturnDays: returnDays,
|
|
Notes: strings.TrimSpace(r.FormValue("notes")),
|
|
}
|
|
|
|
if err := database.UpdatePurchase(h.DB, purchase); err != nil {
|
|
log.Printf("ERROR [%s] handlers: UpdatePurchase: %v", time.Now().Format(time.RFC3339), err)
|
|
http.Error(w, "Failed to update purchase", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Return updated purchase list.
|
|
userID := getUserID(r)
|
|
purchases, err := database.GetPurchasesByUser(h.DB, userID)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] handlers: UpdatePurchase: fetch purchases: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
http.Error(w, "Failed to fetch purchases", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
for i := range purchases {
|
|
purchases[i].ImagePath = normalizeImagePath(purchases[i].ImagePath)
|
|
}
|
|
|
|
listBuf := renderPurchaseList(purchases)
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprintf(w, `<div id="receipt-form" hx-swap-oob="true"><div style="background: #064e3b; border: 1px solid #065f46; color: #6ee7b7; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">Purchase updated!</div></div>`)
|
|
fmt.Fprintf(w, `<div id="purchase-list" hx-swap-oob="true">%s</div>`, listBuf)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DELETE /purchases/{id} — DeletePurchase
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// DeletePurchase removes an individual purchase after verifying ownership.
|
|
func (h *PurchaseHandler) DeletePurchase(w http.ResponseWriter, r *http.Request) {
|
|
purchaseID := chi.URLParam(r, "id")
|
|
if purchaseID == "" {
|
|
http.Error(w, "Missing purchase ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
existing, err := database.GetPurchaseByID(h.DB, purchaseID)
|
|
if err != nil || existing == nil {
|
|
log.Printf("ERROR [%s] handlers: DeletePurchase: get existing(%s): %v",
|
|
time.Now().Format(time.RFC3339), purchaseID, err)
|
|
http.Error(w, "Purchase not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if existing.UserID != getUserID(r) {
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
userID := existing.UserID
|
|
|
|
if err := database.DeletePurchase(h.DB, purchaseID); err != nil {
|
|
log.Printf("ERROR [%s] handlers: DeletePurchase: %v", time.Now().Format(time.RFC3339), err)
|
|
http.Error(w, "Failed to delete purchase", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Fetch the updated purchase list.
|
|
purchases, err := database.GetPurchasesByUser(h.DB, userID)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] handlers: DeletePurchase: fetch purchases: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
http.Error(w, "Failed to fetch purchases", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
for i := range purchases {
|
|
purchases[i].ImagePath = normalizeImagePath(purchases[i].ImagePath)
|
|
}
|
|
|
|
listBuf := renderPurchaseList(purchases)
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprintf(w, `<div id="receipt-form" hx-swap-oob="true"></div>`)
|
|
fmt.Fprintf(w, `<div id="purchase-list" hx-swap-oob="true">%s</div>`, listBuf)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Purchase list rendering helper
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// renderPurchaseList renders the purchase list HTML fragment.
|
|
func renderPurchaseList(purchases []database.Purchase) string {
|
|
var buf strings.Builder
|
|
buf.WriteString(fmt.Sprintf(`<h3 style="margin-bottom: 1rem; font-size: 1rem; font-weight: 600;">Purchases (%d)</h3>`, len(purchases)))
|
|
|
|
if len(purchases) == 0 {
|
|
buf.WriteString(`<div style="text-align: center; padding: 3rem; color: var(--color-text-muted); font-size: 0.875rem;"><p>No purchases yet. Upload a receipt to get started.</p></div>`)
|
|
return buf.String()
|
|
}
|
|
|
|
buf.WriteString(`<div style="display: flex; flex-direction: column; gap: 0.5rem;">`)
|
|
for _, p := range purchases {
|
|
buf.WriteString(renderPurchaseItem(p))
|
|
}
|
|
buf.WriteString(`</div>`)
|
|
return buf.String()
|
|
}
|
|
|
|
// renderPurchaseItem renders a single purchase item HTML.
|
|
func renderPurchaseItem(p database.Purchase) string {
|
|
warrantyInfo := ""
|
|
if p.WarrantyMonths > 0 {
|
|
warrantyInfo = fmt.Sprintf(`<div style="font-size: 0.7rem; color: #6ee7b7;">Warranty: %d months</div>`, p.WarrantyMonths)
|
|
}
|
|
returnInfo := ""
|
|
if p.ReturnDays > 0 {
|
|
returnInfo = fmt.Sprintf(`<div style="font-size: 0.7rem; color: #fcd34d;">Return: %d days</div>`, p.ReturnDays)
|
|
}
|
|
|
|
notesInfo := ""
|
|
if p.Notes != "" {
|
|
notesInfo = fmt.Sprintf(`<div style="font-size: 0.7rem; color: var(--color-text-muted);">%s</div>`, template.HTMLEscapeString(p.Notes))
|
|
}
|
|
|
|
imgBtn := ""
|
|
imgDiv := ""
|
|
if p.ImagePath != "" {
|
|
imgBtn = fmt.Sprintf(`<button class="btn btn-sm" style="background: none; border: 1px solid var(--color-border); border-radius: 0.375rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer; flex-shrink: 0; color: var(--color-text);"
|
|
onclick="document.getElementById('img-%s').classList.toggle('hidden')"
|
|
title="View receipt image">🖼️</button>`, p.ID)
|
|
imgDiv = fmt.Sprintf(`<div id="img-%s" class="hidden" style="position: fixed; inset: 0; background: rgba(0,0,0,0.85); z-index: 999; align-items: center; justify-content: center; cursor: pointer; padding: 1rem;"
|
|
onclick="this.classList.add('hidden')">
|
|
<span style="position: absolute; top: 1rem; right: 1rem; font-size: 2rem; color: #fff; line-height: 1; cursor: pointer; z-index: 1000;">×</span>
|
|
<img src="/storage/%s" alt="Receipt" style="max-width: 100%%; max-height: 100%%; object-fit: contain; border-radius: 0.5rem;" onclick="event.stopPropagation()">
|
|
</div>`, p.ID, template.HTMLEscapeString(p.ImagePath))
|
|
}
|
|
|
|
return fmt.Sprintf(`<div style="display: flex; align-items: center; background: var(--color-card); border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 0.75rem;">
|
|
<div style="flex: 1; min-width: 0;">
|
|
<div style="font-weight: 500; color: var(--color-text);">%s</div>
|
|
<div style="font-size: 0.75rem; color: var(--color-text-muted);">%s · %s · %s %s</div>
|
|
%s%s%s
|
|
</div>
|
|
<div style="text-align: right; margin-right: 0.75rem;">
|
|
<div style="font-weight: 600; color: var(--color-text);">%s %.2f</div>
|
|
</div>
|
|
<div style="display: flex; gap: 0.25rem;">
|
|
%s
|
|
<button class="btn btn-sm" style="background: none; border: 1px solid var(--color-border); border-radius: 0.375rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer; flex-shrink: 0; color: var(--color-text);"
|
|
hx-get="/purchases/%s/edit"
|
|
hx-target="#receipt-form"
|
|
hx-swap="innerHTML"
|
|
title="Edit purchase">✏️</button>
|
|
<button class="btn btn-sm" style="background: none; border: 1px solid #7f1d1d; border-radius: 0.375rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer; flex-shrink: 0; color: #fca5a5;"
|
|
onclick="if(confirm('Delete this purchase?')) htmx.trigger('#del-%s','click')"
|
|
title="Delete purchase">🗑️</button>
|
|
<div hx-delete="/purchases/%s" hx-target="#purchase-list" hx-swap="outerHTML" id="del-%s" style="display:none"></div>
|
|
</div>
|
|
</div>%s`,
|
|
template.HTMLEscapeString(p.ProductName),
|
|
template.HTMLEscapeString(p.Store), template.HTMLEscapeString(p.Category), template.HTMLEscapeString(p.PurchaseDate), template.HTMLEscapeString(p.Currency),
|
|
warrantyInfo, returnInfo, notesInfo,
|
|
template.HTMLEscapeString(p.Currency), p.Amount,
|
|
imgBtn,
|
|
template.HTMLEscapeString(p.ID), template.HTMLEscapeString(p.ID), template.HTMLEscapeString(p.ID),
|
|
imgDiv)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared helpers (from expenses.go)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// renderUploadError writes an HTMX-compatible error fragment.
|
|
func renderUploadError(w http.ResponseWriter, message string) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprintf(w, `<div id="receipt-form"><div style="background: #450a0a; border: 1px solid #7f1d1d; color: #fca5a5; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">%s</div></div>`,
|
|
template.HTMLEscapeString(message))
|
|
}
|
|
|
|
// detectImageExtension examines the magic bytes of the provided data to
|
|
// determine its image format. Supports JPEG, PNG, WebP, GIF, BMP, TIFF,
|
|
// HEIC/HEIF, and PDF.
|
|
func detectImageExtension(data []byte) string {
|
|
if len(data) < 4 {
|
|
return ""
|
|
}
|
|
|
|
// JPEG: FF D8 FF
|
|
if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
|
|
return "jpg"
|
|
}
|
|
|
|
// PNG: 89 50 4E 47
|
|
if len(data) >= 8 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E &&
|
|
data[3] == 0x47 && data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A {
|
|
return "png"
|
|
}
|
|
|
|
// WebP
|
|
if len(data) >= 12 && data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 &&
|
|
data[3] == 0x46 && data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50 {
|
|
return "webp"
|
|
}
|
|
|
|
// GIF
|
|
if len(data) >= 6 && data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 &&
|
|
data[3] == 0x38 && (data[4] == 0x39 || data[4] == 0x37) && data[5] == 0x61 {
|
|
return "gif"
|
|
}
|
|
|
|
// BMP
|
|
if data[0] == 0x42 && data[1] == 0x4D {
|
|
return "bmp"
|
|
}
|
|
|
|
// TIFF
|
|
if (data[0] == 0x49 && data[1] == 0x49 && data[2] == 0x2A && data[3] == 0x00) ||
|
|
(data[0] == 0x4D && data[1] == 0x4D && data[2] == 0x00 && data[3] == 0x2A) {
|
|
return "tiff"
|
|
}
|
|
|
|
// PDF
|
|
if len(data) >= 4 && data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46 {
|
|
return "pdf"
|
|
}
|
|
|
|
// HEIC/HEIF/AVIF
|
|
if len(data) >= 12 && data[4] == 0x66 && data[5] == 0x74 && data[6] == 0x79 && data[7] == 0x70 {
|
|
brand := string(data[8:12])
|
|
switch brand {
|
|
case "heic", "heix", "hevc", "hevx", "mif1", "msf1":
|
|
return "heic"
|
|
case "avif":
|
|
return "avif"
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// resizeImage resizes image data to a maximum of 2048 pixels on the longest
|
|
// side while maintaining aspect ratio. Output is always JPEG at 85% quality.
|
|
func resizeImage(data []byte) ([]byte, error) {
|
|
img, _, err := image.Decode(bytes.NewReader(data))
|
|
if err != nil {
|
|
return data, err
|
|
}
|
|
|
|
bounds := img.Bounds()
|
|
w, h := bounds.Dx(), bounds.Dy()
|
|
|
|
const maxDim = 2048
|
|
if w <= maxDim && h <= maxDim {
|
|
return data, nil
|
|
}
|
|
|
|
var newW, newH int
|
|
if w > h {
|
|
newW = maxDim
|
|
newH = h * maxDim / w
|
|
} else {
|
|
newH = maxDim
|
|
newW = w * maxDim / h
|
|
}
|
|
|
|
dst := image.NewRGBA(image.Rect(0, 0, newW, newH))
|
|
draw.CatmullRom.Scale(dst, dst.Bounds(), img, bounds, draw.Over, nil)
|
|
|
|
var buf bytes.Buffer
|
|
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 85}); err != nil {
|
|
return data, err
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|