diff --git a/cmd/build/main.go b/cmd/build/main.go index a2832ac..efe6c39 100644 --- a/cmd/build/main.go +++ b/cmd/build/main.go @@ -45,7 +45,6 @@ func main() { // Stats for static build staticStats := layouts.AdminStats{ - GalleryCount: 30, UnreadContacts: 0, PageImages: 18, EditableContent: int64(sitecontent.TotalFieldCount()), @@ -53,10 +52,6 @@ func main() { staticContent := sitecontent.Defaults() - // Gallery items for static build - galleryItems := getStaticGalleryItems() - galleryCategories := getStaticGalleryCategories() - // Define pages to generate staticPages := []struct { path string @@ -69,7 +64,6 @@ func main() { {"/contact/index.html", pages.Contact(staticContent, false, "")}, {"/terms/index.html", pages.Terms()}, {"/privacy/index.html", pages.Privacy()}, - {"/gallery/index.html", pages.Gallery(galleryItems, galleryCategories)}, {"/sign-in/index.html", pages.SignIn()}, {"/sign-up/index.html", pages.SignUp()}, {"/admin/index.html", pages.AdminDashboard(staticStats, []models.ContactSubmission{})}, @@ -175,59 +169,3 @@ func copyFile(src, dst string) error { _, err = io.Copy(dstFile, srcFile) return err } - -// getStaticGalleryItems returns hardcoded gallery items for static build -func getStaticGalleryItems() []models.GalleryItem { - return []models.GalleryItem{ - // Original items - {ID: 1, Title: "CNC Milling Operation", Category: "CNC Machining", Description: "Precision 5-axis CNC milling for complex geometries", ImageUrl: "/static/images/customer/machined-cavity-detail.jpg", SortOrder: 1, IsFeatured: true}, - {ID: 2, Title: "Laser Cutting", Category: "Laser", Description: "High-precision laser cutting and engraving", ImageUrl: "/static/images/customer/laser-marking-foba.jpg", SortOrder: 2, IsFeatured: false}, - {ID: 3, Title: "Welding & Fabrication", Category: "Welding", Description: "Professional TIG and MIG welding services", ImageUrl: "/static/images/customer/mold-repair-workstation.jpg", SortOrder: 3, IsFeatured: false}, - {ID: 4, Title: "3D Printing", Category: "3D Printing", Description: "Rapid prototyping with industrial FDM and SLA", ImageUrl: "/static/images/customer/mold-repair-microscope.jpg", SortOrder: 4, IsFeatured: false}, - {ID: 5, Title: "CNC Lathe Work", Category: "CNC Machining", Description: "Precision turning for cylindrical components", ImageUrl: "/static/images/customer/machined-cavity-detail.jpg", SortOrder: 5, IsFeatured: false}, - {ID: 6, Title: "Plasma Cutting", Category: "Plasma", Description: "Heavy-duty plasma cutting for thick materials", ImageUrl: "/static/images/customer/mold-repair-workstation.jpg", SortOrder: 6, IsFeatured: false}, - {ID: 7, Title: "Metal Fabrication", Category: "Welding", Description: "Custom metal fabrication and assembly", ImageUrl: "/static/images/customer/mold-repair-workstation.jpg", SortOrder: 7, IsFeatured: false}, - {ID: 8, Title: "Industrial Automation", Category: "EOAT", Description: "End-of-arm tooling for robotics", ImageUrl: "/static/images/customer/laser-marking-foba.jpg", SortOrder: 8, IsFeatured: false}, - {ID: 9, Title: "Precision Grinding", Category: "CNC Machining", Description: "Surface grinding to tight tolerances", ImageUrl: "/static/images/customer/machined-cavity-detail.jpg", SortOrder: 9, IsFeatured: false}, - {ID: 10, Title: "Mold Components", Category: "Mold Repair", Description: "Precision mold inserts and components", ImageUrl: "/static/images/customer/machined-cavity-detail.jpg", SortOrder: 10, IsFeatured: false}, - {ID: 11, Title: "Laser Engraving", Category: "Laser", Description: "Detailed marking and engraving services", ImageUrl: "/static/images/customer/laser-marking-foba.jpg", SortOrder: 11, IsFeatured: false}, - {ID: 12, Title: "Prototyping", Category: "3D Printing", Description: "Fast turnaround prototype development", ImageUrl: "/static/images/customer/machined-cavity-detail.jpg", SortOrder: 12, IsFeatured: false}, - // Additional items - {ID: 13, Title: "Injection Mold Repair", Category: "Mold Repair", Description: "Complete restoration of damaged injection molds to original specifications", ImageUrl: "/static/images/customer/mold-repair-workstation.jpg", SortOrder: 13, IsFeatured: true}, - {ID: 14, Title: "Mold Polishing", Category: "Mold Repair", Description: "Mirror finish polishing for improved part quality", ImageUrl: "/static/images/customer/mold-repair-workstation.jpg", SortOrder: 14, IsFeatured: false}, - {ID: 15, Title: "Mold Welding Repair", Category: "Mold Repair", Description: "Precision TIG welding for mold surface restoration", ImageUrl: "/static/images/customer/mold-repair-bench.jpg", SortOrder: 15, IsFeatured: false}, - {ID: 16, Title: "Assembly Fixture", Category: "Fixtures", Description: "Custom assembly fixtures for production efficiency", ImageUrl: "/static/images/customer/mold-repair-microscope.jpg", SortOrder: 16, IsFeatured: true}, - {ID: 17, Title: "Welding Fixture", Category: "Fixtures", Description: "Precision welding fixtures for consistent part alignment", ImageUrl: "/static/images/customer/mold-repair-workstation.jpg", SortOrder: 17, IsFeatured: false}, - {ID: 18, Title: "Inspection Fixture", Category: "Fixtures", Description: "Quality control fixtures for dimensional verification", ImageUrl: "/static/images/customer/mold-repair-microscope.jpg", SortOrder: 18, IsFeatured: false}, - {ID: 19, Title: "Machining Fixture", Category: "Fixtures", Description: "Work-holding fixtures for CNC operations", ImageUrl: "/static/images/customer/machined-cavity-detail.jpg", SortOrder: 19, IsFeatured: false}, - {ID: 20, Title: "Robot Gripper", Category: "EOAT", Description: "Custom end-of-arm gripper for automated handling", ImageUrl: "/static/images/customer/laser-marking-foba.jpg", SortOrder: 20, IsFeatured: true}, - {ID: 21, Title: "Vacuum End Effector", Category: "EOAT", Description: "Suction cup tooling for sheet material handling", ImageUrl: "/static/images/customer/laser-marking-foba.jpg", SortOrder: 21, IsFeatured: false}, - {ID: 22, Title: "Multi-Part Gripper", Category: "EOAT", Description: "Complex gripper systems for multiple part pickup", ImageUrl: "/static/images/customer/laser-marking-foba.jpg", SortOrder: 22, IsFeatured: false}, - {ID: 23, Title: "5-Axis Machining", Category: "CNC Machining", Description: "Complex geometry machining with 5-axis capability", ImageUrl: "/static/images/customer/machined-cavity-detail.jpg", SortOrder: 23, IsFeatured: false}, - {ID: 24, Title: "Aluminum Machining", Category: "CNC Machining", Description: "High-speed machining of aluminum components", ImageUrl: "/static/images/customer/machined-cavity-detail.jpg", SortOrder: 24, IsFeatured: false}, - {ID: 25, Title: "Steel Machining", Category: "CNC Machining", Description: "Heavy-duty machining of steel and tool steel", ImageUrl: "/static/images/customer/machined-cavity-detail.jpg", SortOrder: 25, IsFeatured: false}, - {ID: 26, Title: "Precision Boring", Category: "CNC Machining", Description: "Tight tolerance boring operations", ImageUrl: "/static/images/customer/machined-cavity-detail.jpg", SortOrder: 26, IsFeatured: false}, - {ID: 27, Title: "Quality Inspection", Category: "Quality", Description: "CMM and dimensional inspection services", ImageUrl: "/static/images/customer/cmm-inspection-room.jpg", SortOrder: 27, IsFeatured: false}, - {ID: 28, Title: "Surface Finishing", Category: "Finishing", Description: "Bead blasting and surface treatment", ImageUrl: "/static/images/customer/mold-repair-workstation.jpg", SortOrder: 28, IsFeatured: false}, - {ID: 29, Title: "Assembly Services", Category: "Assembly", Description: "Complete mechanical assembly and testing", ImageUrl: "/static/images/customer/cmm-inspection-room.jpg", SortOrder: 29, IsFeatured: false}, - {ID: 30, Title: "EDM Services", Category: "EDM", Description: "Wire and sinker EDM for complex shapes", ImageUrl: "/static/images/customer/sinker-edm-closeup.jpg", SortOrder: 30, IsFeatured: false}, - } -} - -// getStaticGalleryCategories returns all unique categories -func getStaticGalleryCategories() []string { - return []string{ - "CNC Machining", - "Mold Repair", - "Fixtures", - "EOAT", - "Laser", - "Welding", - "3D Printing", - "Plasma", - "Quality", - "Finishing", - "Assembly", - "EDM", - } -} diff --git a/internal/database/database.go b/internal/database/database.go index 0569989..38fa0c2 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -28,7 +28,17 @@ func New(ctx context.Context, databasePath string) (*DB, error) { } } - conn, err := sql.Open("sqlite", databasePath+"?_foreign_keys=on&_journal_mode=WAL") + // modernc.org/sqlite configures pragmas via `_pragma=NAME(VALUE)` query + // params. The mattn-style `_journal_mode=WAL` / `_foreign_keys=on` params + // are silently ignored, which left the DB in rollback-journal mode with + // synchronous=FULL and foreign keys OFF — causing slow, lock-prone writes + // (a single settings save could take 5-17s). WAL + a busy timeout + + // synchronous=NORMAL gives fast writes with concurrent readers. + dsn := databasePath + "?_pragma=busy_timeout(5000)" + + "&_pragma=journal_mode(WAL)" + + "&_pragma=foreign_keys(on)" + + "&_pragma=synchronous(normal)" + conn, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("unable to open database: %w", err) } diff --git a/internal/database/models/models.go b/internal/database/models/models.go index 2dda6e0..401f1a3 100644 --- a/internal/database/models/models.go +++ b/internal/database/models/models.go @@ -4,34 +4,6 @@ package models import "rowetech/internal/database/sqlc" -// GalleryItem represents a gallery item for templates -type GalleryItem struct { - ID int64 `json:"id"` - Title string `json:"title"` - Category string `json:"category"` - Description string `json:"description"` - ImageUrl string `json:"image_url"` - SortOrder int64 `json:"sort_order"` - IsFeatured bool `json:"is_featured"` -} - -// FromSqlcGalleryItems converts sqlc GalleryItems to models GalleryItems -func FromSqlcGalleryItems(items []sqlc.GalleryItem) []GalleryItem { - result := make([]GalleryItem, len(items)) - for i, item := range items { - result[i] = GalleryItem{ - ID: item.ID, - Title: item.Title, - Category: item.Category, - Description: item.Description.String, - ImageUrl: item.ImageUrl, - SortOrder: item.SortOrder.Int64, - IsFeatured: item.IsFeatured.Int64 == 1, - } - } - return result -} - // ContactSubmission represents a contact form submission type ContactSubmission struct { ID int64 `json:"id"` @@ -93,19 +65,6 @@ func FromSqlcContactSubmission(item sqlc.ContactSubmission) ContactSubmission { } } -// FromSqlcGalleryItem converts a single sqlc GalleryItem to models GalleryItem -func FromSqlcGalleryItem(item sqlc.GalleryItem) GalleryItem { - return GalleryItem{ - ID: item.ID, - Title: item.Title, - Category: item.Category, - Description: item.Description.String, - ImageUrl: item.ImageUrl, - SortOrder: item.SortOrder.Int64, - IsFeatured: item.IsFeatured.Int64 == 1, - } -} - // PageImage represents a page image for templates type PageImage struct { ID int64 `json:"id"` diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 998a0b7..4a79c57 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -2,8 +2,6 @@ package handler import ( "log/slog" - "net/http" - "strconv" "rowetech/internal/clerk" "rowetech/internal/database/models" @@ -20,12 +18,6 @@ func (h *Handler) getAdminStats(ctx echo.Context) layouts.AdminStats { stats := layouts.AdminStats{} c := ctx.Request().Context() - // Count gallery items - items, err := h.db.Queries.ListGalleryItems(c) - if err == nil { - stats.GalleryCount = int64(len(items)) - } - // Count unread contacts unread, err := h.db.Queries.CountUnreadContacts(c) if err == nil { @@ -106,36 +98,6 @@ func (h *Handler) AdminContent(c echo.Context) error { return pages.AdminContent(sitecontent.Definitions(), contentValues, stats).Render(ctx, c.Response().Writer) } -// AdminGallery renders the gallery management page -func (h *Handler) AdminGallery(c echo.Context) error { - ctx := c.Request().Context() - stats := h.getAdminStats(c) - - // Get gallery items - sqlcItems, err := h.db.Queries.ListGalleryItems(ctx) - if err != nil { - slog.Error("failed to list gallery items", "error", err) - sqlcItems = nil - } - items := models.FromSqlcGalleryItems(sqlcItems) - - // Get categories - sqlcCategories, err := h.db.Queries.GetGalleryCategories(ctx) - if err != nil { - slog.Error("failed to get gallery categories", "error", err) - sqlcCategories = nil - } - - // Filter by category if specified - category := c.QueryParam("category") - if category != "" { - sqlcItems, _ = h.db.Queries.ListGalleryItemsByCategory(ctx, category) - items = models.FromSqlcGalleryItems(sqlcItems) - } - - return pages.AdminGallery(items, sqlcCategories, stats).Render(ctx, c.Response().Writer) -} - // AdminContacts renders the contacts management page func (h *Handler) AdminContacts(c echo.Context) error { ctx := c.Request().Context() @@ -235,27 +197,3 @@ func (h *Handler) AdminImages(c echo.Context) error { return pages.AdminImages(imagesByPage, pageOrder, stats).Render(ctx, c.Response().Writer) } - -// APIGetGalleryEditForm returns the edit form for a gallery item -func (h *Handler) APIGetGalleryEditForm(c echo.Context) error { - ctx := c.Request().Context() - idStr := c.Param("id") - - itemID, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - return c.String(http.StatusBadRequest, "Invalid ID") - } - - sqlcItem, err := h.db.Queries.GetGalleryItem(ctx, itemID) - if err != nil { - return c.String(http.StatusNotFound, "Item not found") - } - item := models.FromSqlcGalleryItem(sqlcItem) - - // Get categories for the dropdown - categories, _ := h.db.Queries.GetGalleryCategories(ctx) - - slog.Debug("loading edit form", "id", idStr, "item", item.Title) - - return pages.GalleryEditForm(item, categories).Render(ctx, c.Response().Writer) -} diff --git a/internal/handler/admin_api.go b/internal/handler/admin_api.go index fcbd541..df0b7fe 100644 --- a/internal/handler/admin_api.go +++ b/internal/handler/admin_api.go @@ -22,117 +22,6 @@ import ( "github.com/labstack/echo/v4" ) -// APICreateGalleryItem creates a new gallery item -func (h *Handler) APICreateGalleryItem(c echo.Context) error { - ctx := c.Request().Context() - - title := c.FormValue("title") - category := c.FormValue("category") - imageURL := c.FormValue("image_url") - description := c.FormValue("description") - isFeatured := c.FormValue("is_featured") == "1" - - if title == "" || category == "" || imageURL == "" { - return c.String(http.StatusBadRequest, "Title, category, and image URL are required") - } - - // Get max sort order - items, _ := h.db.Queries.ListGalleryItems(ctx) - maxSortOrder := int64(len(items)) - - featuredInt := int64(0) - if isFeatured { - featuredInt = 1 - } - - sqlcItem, err := h.db.Queries.CreateGalleryItem(ctx, sqlc.CreateGalleryItemParams{ - Title: title, - Category: category, - Description: sql.NullString{String: description, Valid: description != ""}, - ImageUrl: imageURL, - SortOrder: sql.NullInt64{Int64: maxSortOrder, Valid: true}, - IsFeatured: sql.NullInt64{Int64: featuredInt, Valid: true}, - }) - if err != nil { - slog.Error("failed to create gallery item", "error", err) - return c.String(http.StatusInternalServerError, "Failed to create item") - } - - item := models.FromSqlcGalleryItem(sqlcItem) - return pages.GalleryItemCardPartial(item).Render(ctx, c.Response().Writer) -} - -// APIUpdateGalleryItem updates a gallery item -func (h *Handler) APIUpdateGalleryItem(c echo.Context) error { - ctx := c.Request().Context() - - idStr := c.Param("id") - itemID, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - return c.String(http.StatusBadRequest, "Invalid ID") - } - - title := c.FormValue("title") - category := c.FormValue("category") - imageURL := c.FormValue("image_url") - description := c.FormValue("description") - isFeatured := c.FormValue("is_featured") == "1" - - if title == "" || category == "" || imageURL == "" { - return c.String(http.StatusBadRequest, "Title, category, and image URL are required") - } - - // Get existing item for sort order - existingItem, err := h.db.Queries.GetGalleryItem(ctx, itemID) - if err != nil { - return c.String(http.StatusNotFound, "Item not found") - } - - featuredInt := int64(0) - if isFeatured { - featuredInt = 1 - } - - err = h.db.Queries.UpdateGalleryItem(ctx, sqlc.UpdateGalleryItemParams{ - Title: title, - Category: category, - Description: sql.NullString{String: description, Valid: description != ""}, - ImageUrl: imageURL, - SortOrder: existingItem.SortOrder, - IsFeatured: sql.NullInt64{Int64: featuredInt, Valid: true}, - ID: itemID, - }) - if err != nil { - slog.Error("failed to update gallery item", "error", err) - return c.String(http.StatusInternalServerError, "Failed to update item") - } - - // Fetch updated item - sqlcItem, _ := h.db.Queries.GetGalleryItem(ctx, itemID) - item := models.FromSqlcGalleryItem(sqlcItem) - - return pages.GalleryItemCardPartial(item).Render(ctx, c.Response().Writer) -} - -// APIDeleteGalleryItem deletes a gallery item -func (h *Handler) APIDeleteGalleryItem(c echo.Context) error { - ctx := c.Request().Context() - - idStr := c.Param("id") - itemID, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - return c.String(http.StatusBadRequest, "Invalid ID") - } - - err = h.db.Queries.DeleteGalleryItem(ctx, itemID) - if err != nil { - slog.Error("failed to delete gallery item", "error", err) - return c.String(http.StatusInternalServerError, "Failed to delete item") - } - - return c.String(http.StatusOK, "") -} - // APIMarkContactRead marks a contact as read func (h *Handler) APIMarkContactRead(c echo.Context) error { ctx := c.Request().Context() @@ -291,106 +180,6 @@ func (h *Handler) APIUpdateImageSortOrder(c echo.Context) error { return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) } -// APIUpdateGallerySortOrder updates a gallery item sort order -func (h *Handler) APIUpdateGallerySortOrder(c echo.Context) error { - ctx := c.Request().Context() - - idStr := c.Param("id") - itemID, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - return c.String(http.StatusBadRequest, "Invalid ID") - } - - var body struct { - SortOrder int `json:"sort_order"` - } - if err := c.Bind(&body); err != nil { - return c.String(http.StatusBadRequest, "Invalid request body") - } - - err = h.db.Queries.UpdateGallerySortOrder(ctx, sqlc.UpdateGallerySortOrderParams{ - SortOrder: sql.NullInt64{Int64: int64(body.SortOrder), Valid: true}, - ID: itemID, - }) - if err != nil { - slog.Error("failed to update gallery sort order", "error", err) - return c.String(http.StatusInternalServerError, "Failed to update sort order") - } - - return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) -} - -// APIUploadImage handles image file uploads for gallery items -func (h *Handler) APIUploadImage(c echo.Context) error { - // Get the uploaded file - file, err := c.FormFile("image") - if err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "No image file provided"}) - } - - // Validate file type - ext := strings.ToLower(filepath.Ext(file.Filename)) - allowedExts := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true} - if !allowedExts[ext] { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid file type. Allowed: jpg, jpeg, png, gif, webp"}) - } - - // Validate file size (max 10MB) - if file.Size > 10*1024*1024 { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "File too large. Maximum size is 10MB"}) - } - - // Generate unique filename - randomBytes := make([]byte, 16) - if _, err := rand.Read(randomBytes); err != nil { - slog.Error("failed to generate random filename", "error", err) - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to process upload"}) - } - filename := hex.EncodeToString(randomBytes) + ext - - // Ensure upload directory exists - uploadDir := "static/uploads/gallery" - if err := os.MkdirAll(uploadDir, 0755); err != nil { - slog.Error("failed to create upload directory", "error", err) - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to process upload"}) - } - - // Open the uploaded file - src, err := file.Open() - if err != nil { - slog.Error("failed to open uploaded file", "error", err) - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to process upload"}) - } - defer func() { - if closeErr := src.Close(); closeErr != nil { - slog.Error("failed to close upload source", "error", closeErr) - } - }() - - // Create destination file - dstPath := filepath.Join(uploadDir, filename) - dst, err := os.Create(dstPath) - if err != nil { - slog.Error("failed to create destination file", "error", err) - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to save file"}) - } - defer func() { - if closeErr := dst.Close(); closeErr != nil { - slog.Error("failed to close upload destination", "error", closeErr) - } - }() - - // Copy the file - if _, err := io.Copy(dst, src); err != nil { - slog.Error("failed to copy file", "error", err) - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to save file"}) - } - - // Return the URL path to the uploaded file - imageURL := fmt.Sprintf("/static/uploads/gallery/%s", filename) - return c.JSON(http.StatusOK, map[string]string{"url": imageURL}) -} - // APIUpdateSetting updates a site setting func (h *Handler) APIUpdateSetting(c echo.Context) error { ctx := c.Request().Context() diff --git a/internal/handler/api.go b/internal/handler/api.go index 3e4f871..46dc18f 100644 --- a/internal/handler/api.go +++ b/internal/handler/api.go @@ -1,43 +1,78 @@ package handler import ( - "net/http" + "database/sql" + "log/slog" + + "rowetech/internal/database/sqlc" + "rowetech/internal/notify" + "rowetech/templates/pages" "github.com/labstack/echo/v4" ) -type ContactRequest struct { - Name string `json:"name"` - Company string `json:"company"` - Email string `json:"email"` - Phone string `json:"phone"` - ProjectType string `json:"projectType"` - Message string `json:"message"` -} - +// APIContactSubmit handles public contact form submissions. The form posts here +// (everything goes through /api routes) and we re-render the contact page with a +// success or error message. func (h *Handler) APIContactSubmit(c echo.Context) error { - var req ContactRequest - if err := c.Bind(&req); err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"}) - } + ctx := c.Request().Context() - if req.Name == "" || req.Email == "" || req.Message == "" { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "Missing required fields"}) - } + name := c.FormValue("name") + company := c.FormValue("company") + email := c.FormValue("email") + phone := c.FormValue("phone") + projectType := c.FormValue("projectType") + message := c.FormValue("message") + newsletter := c.FormValue("newsletter") == "1" + agreeToTerms := c.FormValue("agreeToTerms") == "1" - // TODO: Save to database once sqlc is generated - _ = req + // Validate required fields + if name == "" || email == "" || message == "" { + return pages.Contact(h.getPageContent(ctx), false, "Please complete the required fields.").Render(ctx, c.Response().Writer) + } - return c.JSON(http.StatusOK, map[string]string{"status": "success"}) -} + // Validate terms acceptance + if !agreeToTerms { + return pages.Contact(h.getPageContent(ctx), false, "You must agree to the Terms of Service to submit this form.").Render(ctx, c.Response().Writer) + } -func (h *Handler) APIListGallery(c echo.Context) error { - ctx := c.Request().Context() + // Convert booleans to int64 for SQLite + newsletterInt := int64(0) + if newsletter { + newsletterInt = 1 + } + termsInt := int64(1) // Always 1 since we validated above - items, err := h.db.Queries.ListGalleryItems(ctx) + // Save to database + _, err := h.db.Queries.CreateContactSubmission(ctx, sqlc.CreateContactSubmissionParams{ + Name: name, + Company: sql.NullString{String: company, Valid: company != ""}, + Email: email, + Phone: sql.NullString{String: phone, Valid: phone != ""}, + ProjectType: sql.NullString{String: projectType, Valid: projectType != ""}, + Message: message, + NewsletterOptIn: sql.NullInt64{Int64: newsletterInt, Valid: true}, + AgreedToTerms: sql.NullInt64{Int64: termsInt, Valid: true}, + }) if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load gallery"}) + slog.Error("failed to save contact submission", "error", err) + return pages.Contact(h.getPageContent(ctx), false, "There was an error submitting your message. Please try again.").Render(ctx, c.Response().Writer) + } + + if h.mailer != nil && h.mailer.Enabled() { + if err := h.mailer.SendContactNotification(ctx, notify.ContactNotification{ + Name: name, + Company: company, + Email: email, + Phone: phone, + ProjectType: projectType, + Message: message, + NewsletterOpt: newsletter, + SiteURL: h.cfg.Site.URL, + }); err != nil { + slog.Error("failed to send contact notification email", "error", err, "email", email) + } } - return c.JSON(http.StatusOK, items) + return pages.Contact(h.getPageContent(ctx), true, "").Render(ctx, c.Response().Writer) } diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 12e026f..97b9993 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -37,9 +37,7 @@ func (h *Handler) RegisterRoutes(e *echo.Echo) { e.GET("/about", h.About) e.GET("/services", h.Services) e.GET("/capabilities", h.Capabilities) - e.GET("/gallery", h.Gallery) e.GET("/contact", h.Contact) - e.POST("/contact", h.ContactSubmit) e.GET("/terms", h.Terms) e.GET("/privacy", h.Privacy) @@ -47,9 +45,6 @@ func (h *Handler) RegisterRoutes(e *echo.Echo) { e.GET("/sign-in", h.SignIn) e.GET("/sign-up", h.SignUp) e.GET("/unauthorized", h.Unauthorized) - e.GET("/admin/gallery", func(c echo.Context) error { - return c.Redirect(http.StatusFound, "/admin") - }) e.GET("/admin/images", func(c echo.Context) error { return c.Redirect(http.StatusFound, "/admin") }) @@ -69,24 +64,17 @@ func (h *Handler) RegisterRoutes(e *echo.Echo) { admin.GET("/settings", h.AdminSettings) // Admin API routes - admin.GET("/api/gallery/:id/edit", h.APIGetGalleryEditForm) - admin.POST("/api/gallery", h.APICreateGalleryItem) - admin.PUT("/api/gallery/:id", h.APIUpdateGalleryItem) - admin.DELETE("/api/gallery/:id", h.APIDeleteGalleryItem) - admin.PUT("/api/gallery/:id/sort", h.APIUpdateGallerySortOrder) admin.POST("/api/contacts/:id/read", h.APIMarkContactRead) admin.POST("/api/contacts/:id/unread", h.APIMarkContactUnread) admin.DELETE("/api/contacts/:id", h.APIDeleteContact) admin.PUT("/api/images/:id/url", h.APIUpdateImageURL) admin.PUT("/api/images/:id/alt", h.APIUpdateImageAlt) admin.PUT("/api/images/:id/sort", h.APIUpdateImageSortOrder) - admin.POST("/api/upload/image", h.APIUploadImage) admin.POST("/api/upload/page-image/:id", h.APIUploadPageImage) admin.POST("/api/settings", h.APIUpdateSetting) // Public API routes api := e.Group("/api") api.POST("/contact", h.APIContactSubmit) - api.GET("/gallery", h.APIListGallery) api.GET("/is-admin", h.APIIsAdmin) } diff --git a/internal/handler/pages.go b/internal/handler/pages.go index ee960b6..67bb3d2 100644 --- a/internal/handler/pages.go +++ b/internal/handler/pages.go @@ -2,13 +2,9 @@ package handler import ( "context" - "database/sql" "log/slog" "net/http" - "rowetech/internal/database/models" - "rowetech/internal/database/sqlc" - "rowetech/internal/notify" "rowetech/internal/sitecontent" "rowetech/templates/pages" @@ -39,95 +35,11 @@ func (h *Handler) Capabilities(c echo.Context) error { return pages.Capabilities(h.getPageContent(ctx)).Render(ctx, c.Response().Writer) } -func (h *Handler) Gallery(c echo.Context) error { - ctx := c.Request().Context() - - // Get gallery items from database - sqlcItems, err := h.db.Queries.ListGalleryItems(ctx) - if err != nil { - return c.String(http.StatusInternalServerError, "Failed to load gallery") - } - - // Convert to models - items := models.FromSqlcGalleryItems(sqlcItems) - - // Get categories - categories, err := h.db.Queries.GetGalleryCategories(ctx) - if err != nil { - categories = []string{} - } - - return pages.Gallery(items, categories).Render(ctx, c.Response().Writer) -} - func (h *Handler) Contact(c echo.Context) error { ctx := c.Request().Context() return pages.Contact(h.getPageContent(ctx), false, "").Render(ctx, c.Response().Writer) } -func (h *Handler) ContactSubmit(c echo.Context) error { - ctx := c.Request().Context() - - name := c.FormValue("name") - company := c.FormValue("company") - email := c.FormValue("email") - phone := c.FormValue("phone") - projectType := c.FormValue("projectType") - message := c.FormValue("message") - newsletter := c.FormValue("newsletter") == "1" - agreeToTerms := c.FormValue("agreeToTerms") == "1" - - // Validate required fields - if name == "" || email == "" || message == "" { - return pages.Contact(h.getPageContent(ctx), false, "Please complete the required fields.").Render(ctx, c.Response().Writer) - } - - // Validate terms acceptance - if !agreeToTerms { - return pages.Contact(h.getPageContent(ctx), false, "You must agree to the Terms of Service to submit this form.").Render(ctx, c.Response().Writer) - } - - // Convert booleans to int64 for SQLite - newsletterInt := int64(0) - if newsletter { - newsletterInt = 1 - } - termsInt := int64(1) // Always 1 since we validated above - - // Save to database - _, err := h.db.Queries.CreateContactSubmission(ctx, sqlc.CreateContactSubmissionParams{ - Name: name, - Company: sql.NullString{String: company, Valid: company != ""}, - Email: email, - Phone: sql.NullString{String: phone, Valid: phone != ""}, - ProjectType: sql.NullString{String: projectType, Valid: projectType != ""}, - Message: message, - NewsletterOptIn: sql.NullInt64{Int64: newsletterInt, Valid: true}, - AgreedToTerms: sql.NullInt64{Int64: termsInt, Valid: true}, - }) - if err != nil { - slog.Error("failed to save contact submission", "error", err) - return pages.Contact(h.getPageContent(ctx), false, "There was an error submitting your message. Please try again.").Render(ctx, c.Response().Writer) - } - - if h.mailer != nil && h.mailer.Enabled() { - if err := h.mailer.SendContactNotification(ctx, notify.ContactNotification{ - Name: name, - Company: company, - Email: email, - Phone: phone, - ProjectType: projectType, - Message: message, - NewsletterOpt: newsletter, - SiteURL: h.cfg.Site.URL, - }); err != nil { - slog.Error("failed to send contact notification email", "error", err, "email", email) - } - } - - return pages.Contact(h.getPageContent(ctx), true, "").Render(ctx, c.Response().Writer) -} - func (h *Handler) getPageContent(ctx context.Context) map[string]string { values := sitecontent.Defaults() diff --git a/internal/middleware/clerk.go b/internal/middleware/clerk.go index 8b03482..4df2d0c 100644 --- a/internal/middleware/clerk.go +++ b/internal/middleware/clerk.go @@ -33,7 +33,7 @@ var clerkJWKS jwksCache func RequireAdminAccess(cfg *config.Config) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { - if c.Request().URL.Path == "/admin/gallery" || c.Request().URL.Path == "/admin/images" { + if c.Request().URL.Path == "/admin/images" { return c.Redirect(http.StatusFound, "/admin") } diff --git a/static/js/admin.js b/static/js/admin.js index e59cb46..b339634 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -66,6 +66,11 @@ document.addEventListener('DOMContentLoaded', function() { window.Clerk.load().then(() => { updateAdminUserInfo(); }); + } else { + // No Clerk configured (e.g. dev auth bypass) — clear the loading placeholder + // so the sidebar doesn't sit on "Loading..." forever. + const sidebarEmail = document.getElementById('sidebar-user-email'); + if (sidebarEmail) sidebarEmail.textContent = ''; } }); diff --git a/templates/layouts/admin.templ b/templates/layouts/admin.templ index f890d91..b9db5b0 100644 --- a/templates/layouts/admin.templ +++ b/templates/layouts/admin.templ @@ -8,9 +8,8 @@ import ( // AdminStats holds the dashboard statistics type AdminStats struct { - GalleryCount int64 - UnreadContacts int64 - PageImages int64 + UnreadContacts int64 + PageImages int64 EditableContent int64 } diff --git a/templates/pages/admin_gallery.templ b/templates/pages/admin_gallery.templ deleted file mode 100644 index 1571213..0000000 --- a/templates/pages/admin_gallery.templ +++ /dev/null @@ -1,376 +0,0 @@ -package pages - -import ( - "fmt" - "rowetech/internal/database/models" - "rowetech/internal/meta" - "rowetech/templates/layouts" -) - -templ AdminGallery(items []models.GalleryItem, categories []string, stats layouts.AdminStats) { - @layouts.Admin(meta.New("Gallery Management", "Manage gallery items.").WithNoIndex(), "gallery", stats) { -
- -
-
-

Gallery

-

Manage your portfolio gallery items.

-
- -
- -
-
-
- - -
-
-

{ fmt.Sprintf("%d items", len(items)) }

-
-
-
- - -
- - @addGalleryModal(categories) - - - } -} - -templ galleryItemCard(item models.GalleryItem) { -
- -
- - - -
- - if item.IsFeatured { -
- Featured -
- } - -
- { -
-
- -
-
-

{ item.Title }

- - { item.Category } - -
- if item.Description != "" { -

{ item.Description }

- } - -
- - -
-
-
-} - -templ addGalleryModal(categories []string) { - -} - -// GalleryItemCardPartial renders a single gallery item card (for HTMX updates) -templ GalleryItemCardPartial(item models.GalleryItem) { - @galleryItemCard(item) -} - -templ GalleryEditForm(item models.GalleryItem, categories []string) { -
-
- - -
-
- - - - for _, cat := range categories { - - } - -
-
- - -
- Current image -
- -
- -
- -
- or enter URL: -
- -
-
- - -
-
- - -
-
- - -
-
-} diff --git a/templates/pages/contact.templ b/templates/pages/contact.templ index 4ec0791..dd54e02 100644 --- a/templates/pages/contact.templ +++ b/templates/pages/contact.templ @@ -47,7 +47,7 @@ templ Contact(content map[string]string, success bool, errorMsg string) { } -
+
diff --git a/templates/pages/gallery.templ b/templates/pages/gallery.templ deleted file mode 100644 index f806558..0000000 --- a/templates/pages/gallery.templ +++ /dev/null @@ -1,80 +0,0 @@ -package pages - -import ( - "rowetech/internal/meta" - "rowetech/internal/database/models" - "rowetech/templates/layouts" -) - -templ Gallery(items []models.GalleryItem, categories []string) { - @layouts.Base(meta.New("Gallery", "Explore RoweTech's machining work, equipment, and project gallery.")) { - -
-
- Laser marking workstation -
-
-
-

Gallery

-

- Examples of our precision machining, mold repair, and custom tooling projects. -

-
-
- - -
-
- - - - - - - if len(items) == 0 { -
-

No gallery items yet. Check back soon!

-
- } -
-
- - -
- Mold repair equipment -
-
-

Like What You See?

-

- Contact us to discuss your project. -

- Start Your Project -
-
- } -} - -templ galleryItem(item models.GalleryItem) { - -}