Implements complete offline-first architecture with SQLite caching and MariaDB synchronization. Key features: - Local SQLite database for offline operation (data/quoteforge.db) - Connection settings with encrypted credentials - Component and pricelist caching with auto-sync - Sync API endpoints (/api/sync/status, /components, /pricelists, /all) - Real-time sync status indicator in UI with auto-refresh - Offline mode detection middleware - Migration tool for database initialization - Setup wizard for initial configuration New components: - internal/localdb: SQLite repository layer (components, pricelists, sync) - internal/services/sync: Synchronization service - internal/handlers/sync: Sync API handlers - internal/handlers/setup: Setup wizard handlers - internal/middleware/offline: Offline detection - cmd/migrate: Database migration tool UI improvements: - Setup page for database configuration - Sync status indicator with online/offline detection - Warning icons for pending synchronization - Auto-refresh every 30 seconds Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
195 lines
4.9 KiB
Go
195 lines
4.9 KiB
Go
package handlers
|
|
|
|
import (
|
|
"html/template"
|
|
"path/filepath"
|
|
"strconv"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
|
"git.mchus.pro/mchus/quoteforge/internal/services"
|
|
)
|
|
|
|
type WebHandler struct {
|
|
templates map[string]*template.Template
|
|
componentService *services.ComponentService
|
|
}
|
|
|
|
func NewWebHandler(templatesPath string, componentService *services.ComponentService) (*WebHandler, error) {
|
|
funcMap := template.FuncMap{
|
|
"sub": func(a, b int) int { return a - b },
|
|
"add": func(a, b int) int { return a + b },
|
|
"mul": func(a, b int) int { return a * b },
|
|
"div": func(a, b int) int {
|
|
if b == 0 {
|
|
return 0
|
|
}
|
|
return (a + b - 1) / b
|
|
},
|
|
"deref": func(f *float64) float64 {
|
|
if f == nil {
|
|
return 0
|
|
}
|
|
return *f
|
|
},
|
|
"jsesc": func(s string) string {
|
|
// Escape string for safe use in JavaScript
|
|
result := ""
|
|
for _, r := range s {
|
|
switch r {
|
|
case '\\':
|
|
result += "\\\\"
|
|
case '\'':
|
|
result += "\\'"
|
|
case '"':
|
|
result += "\\\""
|
|
case '\n':
|
|
result += "\\n"
|
|
case '\r':
|
|
result += "\\r"
|
|
case '\t':
|
|
result += "\\t"
|
|
default:
|
|
result += string(r)
|
|
}
|
|
}
|
|
return result
|
|
},
|
|
}
|
|
|
|
templates := make(map[string]*template.Template)
|
|
basePath := filepath.Join(templatesPath, "base.html")
|
|
|
|
// Load each page template with base
|
|
simplePages := []string{"login.html", "configs.html", "admin_pricing.html", "pricelists.html", "pricelist_detail.html"}
|
|
for _, page := range simplePages {
|
|
pagePath := filepath.Join(templatesPath, page)
|
|
tmpl, err := template.New("").Funcs(funcMap).ParseFiles(basePath, pagePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
templates[page] = tmpl
|
|
}
|
|
|
|
// Index page needs components_list.html as well
|
|
indexPath := filepath.Join(templatesPath, "index.html")
|
|
componentsListPath := filepath.Join(templatesPath, "components_list.html")
|
|
indexTmpl, err := template.New("").Funcs(funcMap).ParseFiles(basePath, indexPath, componentsListPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
templates["index.html"] = indexTmpl
|
|
|
|
// Load partial templates (no base needed)
|
|
partials := []string{"components_list.html"}
|
|
for _, partial := range partials {
|
|
partialPath := filepath.Join(templatesPath, partial)
|
|
tmpl, err := template.New("").Funcs(funcMap).ParseFiles(partialPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
templates[partial] = tmpl
|
|
}
|
|
|
|
return &WebHandler{
|
|
templates: templates,
|
|
componentService: componentService,
|
|
}, nil
|
|
}
|
|
|
|
func (h *WebHandler) render(c *gin.Context, name string, data gin.H) {
|
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
|
tmpl, ok := h.templates[name]
|
|
if !ok {
|
|
c.String(500, "Template not found: %s", name)
|
|
return
|
|
}
|
|
// Execute the page template which will use base
|
|
if err := tmpl.ExecuteTemplate(c.Writer, name, data); err != nil {
|
|
c.String(500, "Template error: %v", err)
|
|
}
|
|
}
|
|
|
|
func (h *WebHandler) Index(c *gin.Context) {
|
|
// Redirect to configs page - configurator is accessed via /configurator?uuid=...
|
|
c.Redirect(302, "/configs")
|
|
}
|
|
|
|
func (h *WebHandler) Configurator(c *gin.Context) {
|
|
categories, _ := h.componentService.GetCategories()
|
|
uuid := c.Query("uuid")
|
|
|
|
filter := repository.ComponentFilter{}
|
|
result, err := h.componentService.List(filter, 1, 20)
|
|
|
|
data := gin.H{
|
|
"ActivePage": "configurator",
|
|
"Categories": categories,
|
|
"Components": []interface{}{},
|
|
"Total": int64(0),
|
|
"Page": 1,
|
|
"PerPage": 20,
|
|
"ConfigUUID": uuid,
|
|
}
|
|
|
|
if err == nil && result != nil {
|
|
data["Components"] = result.Components
|
|
data["Total"] = result.Total
|
|
data["Page"] = result.Page
|
|
data["PerPage"] = result.PerPage
|
|
}
|
|
|
|
h.render(c, "index.html", data)
|
|
}
|
|
|
|
func (h *WebHandler) Login(c *gin.Context) {
|
|
h.render(c, "login.html", nil)
|
|
}
|
|
|
|
func (h *WebHandler) Configs(c *gin.Context) {
|
|
h.render(c, "configs.html", gin.H{"ActivePage": "configs"})
|
|
}
|
|
|
|
func (h *WebHandler) AdminPricing(c *gin.Context) {
|
|
h.render(c, "admin_pricing.html", gin.H{"ActivePage": "admin"})
|
|
}
|
|
|
|
func (h *WebHandler) Pricelists(c *gin.Context) {
|
|
h.render(c, "pricelists.html", gin.H{"ActivePage": "pricelists"})
|
|
}
|
|
|
|
func (h *WebHandler) PricelistDetail(c *gin.Context) {
|
|
h.render(c, "pricelist_detail.html", gin.H{"ActivePage": "pricelists"})
|
|
}
|
|
|
|
// Partials for htmx
|
|
|
|
func (h *WebHandler) ComponentsPartial(c *gin.Context) {
|
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
|
|
filter := repository.ComponentFilter{
|
|
Category: c.Query("category"),
|
|
Search: c.Query("search"),
|
|
}
|
|
|
|
data := gin.H{
|
|
"Components": []interface{}{},
|
|
"Total": int64(0),
|
|
"Page": page,
|
|
"PerPage": 20,
|
|
}
|
|
|
|
result, err := h.componentService.List(filter, page, 20)
|
|
if err == nil && result != nil {
|
|
data["Components"] = result.Components
|
|
data["Total"] = result.Total
|
|
data["Page"] = result.Page
|
|
data["PerPage"] = result.PerPage
|
|
}
|
|
|
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
|
if tmpl, ok := h.templates["components_list.html"]; ok {
|
|
tmpl.ExecuteTemplate(c.Writer, "components_list.html", data)
|
|
}
|
|
}
|