12.1 KB
pages.go
package controllers
import (
"encoding/json"
"fmt"
"net/http"
"github.com/readysite/readysite/pkg/application"
"github.com/readysite/readysite/website/internal/access"
"github.com/readysite/readysite/website/internal/helpers"
"github.com/readysite/readysite/website/internal/content"
"github.com/readysite/readysite/website/models"
)
// Pages returns the pages controller.
func Pages() (string, *PagesController) {
return "pages", &PagesController{}
}
// PagesController handles page CRUD operations.
type PagesController struct {
application.BaseController
}
// Setup registers routes.
func (c *PagesController) Setup(app *application.App) {
c.BaseController.Setup(app)
}
// Handle implements Controller interface with value receiver for request isolation.
func (c PagesController) Handle(r *http.Request) application.Controller {
c.Request = r
return &c
}
// PageTreeNode represents a page with its children for tree rendering.
type PageTreeNode = content.TreeNode
// Page returns the current page from the path parameter.
func (c *PagesController) Page() *models.Page {
if c.Request == nil {
return nil
}
id := c.PathValue("id")
if id == "" {
return nil
}
page, err := models.Pages.Get(id)
if err != nil {
return nil
}
return page
}
// RootPages returns top-level pages.
func (c *PagesController) RootPages() []*models.Page {
pages, _ := models.Pages.Search("WHERE ParentID = '' ORDER BY Position, CreatedAt")
return pages
}
// PageTree returns the nested page tree structure.
// Uses single query and builds tree in memory to avoid N+1 queries.
func (c *PagesController) PageTree() []*PageTreeNode {
return content.BuildTree()
}
// AllPages returns all pages for selection dropdowns.
func (c *PagesController) AllPages() []*models.Page {
pages, _ := models.Pages.Search("ORDER BY Position, ID")
return pages
}
// IsNew returns true if creating a new page.
func (c *PagesController) IsNew() bool {
if c.Request == nil {
return true
}
id := c.PathValue("id")
return id == ""
}
// Create creates a new page.
func (c *PagesController) Create(w http.ResponseWriter, r *http.Request) {
slug := r.FormValue("slug")
title := r.FormValue("title")
description := r.FormValue("description")
html := r.FormValue("html")
// Validate slug if provided
if err := content.ValidateSlug(slug); err != nil {
c.RenderError(w, r, err)
return
}
if err := content.CheckSlugAvailable(slug, "page"); err != nil {
c.RenderError(w, r, err)
return
}
// Validate page input fields
if err := content.ValidatePageInput(title, description, html); err != nil {
c.RenderError(w, r, err)
return
}
page := &models.Page{
ParentID: r.FormValue("parent_id"),
}
// Use slug as ID if provided
if slug != "" {
page.ID = slug
}
id, err := models.Pages.Insert(page)
if err != nil {
c.RenderError(w, r, fmt.Errorf("Failed to create page: %w", err))
return
}
// Get current user ID for versioning
userID := ""
if user := access.GetUserFromJWT(r); user != nil {
userID = user.ID
}
// Determine content status based on form checkbox
contentStatus := models.StatusDraft
if r.FormValue("published") == "on" {
contentStatus = models.StatusPublished
}
// Save content as first version with appropriate status
page.ID = id
_, err = content.SavePageContent(
page,
title,
description,
html,
userID,
contentStatus,
)
if err != nil {
c.RenderError(w, r, fmt.Errorf("Failed to save page content: %w", err))
return
}
// Audit log
helpers.AuditCreate(r, userID, helpers.ResourcePage, id, r.FormValue("title"))
w.Header().Set("HX-Trigger", `{"showToast":"Page created"}`)
c.Refresh(w, r)
}
// Update updates an existing page.
func (c *PagesController) Update(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
title := r.FormValue("title")
description := r.FormValue("description")
html := r.FormValue("html")
// Validate page input fields
if err := content.ValidatePageInput(title, description, html); err != nil {
c.RenderError(w, r, err)
return
}
page, err := models.Pages.Get(id)
if err != nil {
c.RenderError(w, r, fmt.Errorf("Page not found"))
return
}
// Update page metadata
page.ParentID = r.FormValue("parent_id")
if err := models.Pages.Update(page); err != nil {
c.RenderError(w, r, fmt.Errorf("Failed to update page: %w", err))
return
}
// Get current user ID for versioning
userID := ""
if user := access.GetUserFromJWT(r); user != nil {
userID = user.ID
}
// Determine content status based on form checkbox
contentStatus := models.StatusDraft
if r.FormValue("published") == "on" {
contentStatus = models.StatusPublished
}
// Save content as new version with appropriate status
_, err = content.SavePageContent(
page,
title,
description,
html,
userID,
contentStatus,
)
if err != nil {
c.RenderError(w, r, fmt.Errorf("Failed to save page content: %w", err))
return
}
// Audit log
helpers.AuditUpdate(r, userID, helpers.ResourcePage, id, r.FormValue("title"), nil)
w.Header().Set("HX-Trigger", `{"showToast":"Page saved"}`)
c.Refresh(w, r)
}
// Delete deletes a page.
func (c *PagesController) Delete(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
page, err := models.Pages.Get(id)
if err != nil {
c.RenderError(w, r, fmt.Errorf("Page not found"))
return
}
// Get page title before deletion
title := ""
if content := page.LatestContent(); content != nil {
title = content.Title
}
if err := models.Pages.Delete(page); err != nil {
c.RenderError(w, r, fmt.Errorf("Failed to delete page: %w", err))
return
}
// Audit log
userID := ""
if user := access.GetUserFromJWT(r); user != nil {
userID = user.ID
}
helpers.AuditDelete(r, userID, helpers.ResourcePage, id, title)
c.Redirect(w, r, "/admin/pages")
}
// TogglePublish toggles the published status of a page's latest content.
func (c *PagesController) TogglePublish(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
page, err := models.Pages.Get(id)
if err != nil {
c.RenderError(w, r, fmt.Errorf("Page not found"))
return
}
// Get latest content
content := page.LatestContent()
if content == nil {
c.RenderError(w, r, fmt.Errorf("Page has no content"))
return
}
// Toggle status
if content.IsPublished() {
content.Status = models.StatusDraft
} else {
content.Status = models.StatusPublished
}
if err := models.PageContents.Update(content); err != nil {
c.RenderError(w, r, fmt.Errorf("Failed to update page status: %w", err))
return
}
c.Refresh(w, r)
}
// Reorder updates page positions based on the new order.
func (c *PagesController) Reorder(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
c.RenderError(w, r, fmt.Errorf("Invalid form data: %w", err))
return
}
pageIDs := r.Form["page_ids"]
if len(pageIDs) == 0 {
c.RenderError(w, r, fmt.Errorf("No pages to reorder"))
return
}
var errors []string
for i, id := range pageIDs {
page, err := models.Pages.Get(id)
if err != nil {
errors = append(errors, fmt.Sprintf("page %s not found", id))
continue
}
page.Position = i
if err := models.Pages.Update(page); err != nil {
errors = append(errors, fmt.Sprintf("failed to update %s: %v", id, err))
}
}
if len(errors) > 0 {
c.RenderError(w, r, fmt.Errorf("Some pages failed to reorder: %v", errors))
return
}
w.WriteHeader(http.StatusOK)
}
// PageContents returns all content versions for the current page.
func (c *PagesController) PageContents() []*models.PageContent {
page := c.Page()
if page == nil {
return nil
}
contents, _ := page.Contents()
return contents
}
// ContentCreator returns the user who created a content version.
func (c *PagesController) ContentCreator(content *models.PageContent) *models.User {
if content == nil || content.CreatedBy == "" {
return nil
}
user, _ := models.Users.Get(content.CreatedBy)
return user
}
// LatestContentID returns the current content version ID for the page.
func (c *PagesController) LatestContentID() string {
page := c.Page()
if page == nil {
return ""
}
if content := page.LatestContent(); content != nil {
return content.ID
}
return ""
}
// SaveCode saves page HTML with version conflict checking.
func (c *PagesController) SaveCode(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
page, err := models.Pages.Get(id)
if err != nil {
c.RenderError(w, r, fmt.Errorf("Page not found"))
return
}
// Check for version conflict
expectedVersion := r.FormValue("version_id")
currentContent := page.LatestContent()
if currentContent != nil && expectedVersion != "" && currentContent.ID != expectedVersion {
// Version conflict - someone else edited the page
w.Header().Set("HX-Retarget", "#code-save-error")
w.Header().Set("HX-Reswap", "innerHTML")
w.WriteHeader(http.StatusConflict)
fmt.Fprintf(w, `<div class="alert alert-warning text-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>This page was modified. <a href="" class="link" onclick="location.reload(); return false;">Reload</a> to see changes.</span>
</div>`)
return
}
// Get current user ID for versioning
userID := ""
if user := access.GetUserFromJWT(r); user != nil {
userID = user.ID
}
// Get current title and description to preserve them
title := ""
description := ""
if currentContent != nil {
title = currentContent.Title
description = currentContent.Description
}
// Save new content version
newContent, err := content.SavePageContent(page, title, description, r.FormValue("html"), userID)
if err != nil {
c.RenderError(w, r, fmt.Errorf("Failed to save: %w", err))
return
}
// Return success with new version ID
w.Header().Set("HX-Trigger", "codeSaved")
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<input type="hidden" name="version_id" id="code-version-id" value="%s" hx-swap-oob="true" />`, newContent.ID)
}
// Restore restores a page to a previous content version.
func (c *PagesController) Restore(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
contentID := r.PathValue("contentId")
page, err := models.Pages.Get(id)
if err != nil {
c.RenderError(w, r, fmt.Errorf("Page not found"))
return
}
oldContent, err := models.PageContents.Get(contentID)
if err != nil {
c.RenderError(w, r, fmt.Errorf("Content version not found"))
return
}
// Create new content version with old content
userID := ""
if user := access.GetUserFromJWT(r); user != nil {
userID = user.ID
}
_, err = content.SavePageContent(page, oldContent.Title, oldContent.Description, oldContent.HTML, userID)
if err != nil {
c.RenderError(w, r, fmt.Errorf("Failed to restore content: %w", err))
return
}
c.Redirect(w, r, "/admin/page/"+id)
}
// GetLatestContent returns the latest content version for a page as JSON.
func (c *PagesController) GetLatestContent(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
page, err := models.Pages.Get(id)
if err != nil {
http.Error(w, "Page not found", http.StatusNotFound)
return
}
content := page.LatestContent()
if content == nil {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"html":""}`))
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"id": content.ID,
"title": content.Title,
"description": content.Description,
"html": content.HTML,
"createdAt": content.CreatedAt,
})
}
// GetContent returns a specific content version for a page as JSON.
func (c *PagesController) GetContent(w http.ResponseWriter, r *http.Request) {
contentID := r.PathValue("contentId")
content, err := models.PageContents.Get(contentID)
if err != nil {
http.Error(w, "Content version not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"id": content.ID,
"title": content.Title,
"description": content.Description,
"html": content.HTML,
"createdAt": content.CreatedAt,
})
}