tracks/internal/handlers/web_handler.go
Claude d2a9c79633
Add inline context creation when creating todos
Users can now create a new context directly from the todo creation modal
without having to navigate away to the contexts page.

Changes:
- Added "Create new context..." option to context dropdown in todo modal
- Added inline form that appears when user selects "Create new context"
- Added JavaScript to show/hide the new context input field dynamically
- Added form validation to ensure either an existing context is selected
  or a new context name is provided
- Updated HandleCreateTodo to detect when user wants to create a new context
  (context_id == "__new__") and create it before creating the todo
- New contexts are created with proper position ordering

UX Flow:
1. User clicks "New Todo"
2. User selects "Create new context..." from dropdown
3. Input field appears below for entering context name
4. User enters context name (e.g., "@home", "@work")
5. When form is submitted, context is created first, then todo is created
   with the new context automatically assigned
6. User is redirected back to todos page with both new context and todo visible

This streamlines the workflow and eliminates context switching when users
need to quickly add a todo with a new context.
2025-11-05 13:21:04 +00:00

522 lines
14 KiB
Go

package handlers
import (
"encoding/xml"
"fmt"
"html/template"
"net/http"
"time"
"github.com/TracksApp/tracks/internal/database"
"github.com/TracksApp/tracks/internal/middleware"
"github.com/TracksApp/tracks/internal/models"
"github.com/TracksApp/tracks/internal/services"
"github.com/gin-gonic/gin"
)
// WebHandler handles web UI routes
type WebHandler struct {
authService *services.AuthService
templates *template.Template
}
// NewWebHandler creates a new WebHandler
func NewWebHandler(authService *services.AuthService, templates *template.Template) *WebHandler {
return &WebHandler{
authService: authService,
templates: templates,
}
}
// ShowLogin displays the login page
func (h *WebHandler) ShowLogin(c *gin.Context) {
// Check if this is first time (no users except admin)
var count int64
database.DB.Model(&models.User{}).Count(&count)
data := gin.H{
"Title": "Login",
"FirstTime": count <= 1,
}
c.Header("Content-Type", "text/html; charset=utf-8")
// Execute base template with content template
err := h.templates.Lookup("base.html").Execute(c.Writer, data)
if err != nil {
c.String(500, "Template error: "+err.Error())
return
}
}
// HandleLogin processes login form submission
func (h *WebHandler) HandleLogin(c *gin.Context) {
login := c.PostForm("login")
password := c.PostForm("password")
// Authenticate user
resp, err := h.authService.Login(services.LoginRequest{
Login: login,
Password: password,
})
if err != nil {
var count int64
database.DB.Model(&models.User{}).Count(&count)
data := gin.H{
"Title": "Login",
"Error": "Invalid username or password",
"FirstTime": count <= 1,
}
c.Header("Content-Type", "text/html; charset=utf-8")
h.templates.ExecuteTemplate(c.Writer, "base.html", data)
return
}
// Set session cookie
c.SetCookie("tracks_token", resp.Token, 60*60*24*7, "/", "", false, true)
// Redirect to dashboard
c.Redirect(http.StatusFound, "/")
}
// HandleLogout logs out the user
func (h *WebHandler) HandleLogout(c *gin.Context) {
// Clear session cookie
c.SetCookie("tracks_token", "", -1, "/", "", false, true)
c.Redirect(http.StatusFound, "/login")
}
// ShowDashboard displays the dashboard
func (h *WebHandler) ShowDashboard(c *gin.Context) {
user, _ := middleware.GetCurrentUser(c)
// Get statistics
var stats struct {
ActiveTodos int64
ActiveProjects int64
ActiveContexts int64
CompletedToday int64
}
database.DB.Model(&models.Todo{}).Where("user_id = ? AND state = ?", user.ID, "active").Count(&stats.ActiveTodos)
database.DB.Model(&models.Project{}).Where("user_id = ? AND state = ?", user.ID, "active").Count(&stats.ActiveProjects)
database.DB.Model(&models.Context{}).Where("user_id = ? AND state = ?", user.ID, "active").Count(&stats.ActiveContexts)
today := time.Now().Truncate(24 * time.Hour)
database.DB.Model(&models.Todo{}).
Where("user_id = ? AND state = ? AND completed_at >= ?", user.ID, "completed", today).
Count(&stats.CompletedToday)
// Get recent todos
var recentTodos []models.Todo
database.DB.
Preload("Context").
Preload("Project").
Where("user_id = ?", user.ID).
Order("created_at DESC").
Limit(10).
Find(&recentTodos)
data := gin.H{
"Title": "Dashboard",
"User": user,
"Stats": stats,
"RecentTodos": recentTodos,
}
c.Header("Content-Type", "text/html; charset=utf-8")
h.templates.ExecuteTemplate(c.Writer, "base.html", data)
}
// ShowAdminUsers displays the user management page
func (h *WebHandler) ShowAdminUsers(c *gin.Context) {
user, _ := middleware.GetCurrentUser(c)
// Get all users
var users []models.User
database.DB.Order("id ASC").Find(&users)
data := gin.H{
"Title": "User Management",
"User": user,
"Users": users,
}
c.Header("Content-Type", "text/html; charset=utf-8")
h.templates.ExecuteTemplate(c.Writer, "base.html", data)
}
// HandleCreateUser processes user creation form
func (h *WebHandler) HandleCreateUser(c *gin.Context) {
user, _ := middleware.GetCurrentUser(c)
login := c.PostForm("login")
password := c.PostForm("password")
firstName := c.PostForm("first_name")
lastName := c.PostForm("last_name")
isAdmin := c.PostForm("is_admin") == "true"
// Create user
_, err := h.authService.CreateUser(services.CreateUserRequest{
Login: login,
Password: password,
FirstName: firstName,
LastName: lastName,
IsAdmin: isAdmin,
})
if err != nil {
// Get all users for re-rendering
var users []models.User
database.DB.Order("id ASC").Find(&users)
data := gin.H{
"Title": "User Management",
"User": user,
"Users": users,
"Error": err.Error(),
}
c.Header("Content-Type", "text/html; charset=utf-8")
h.templates.ExecuteTemplate(c.Writer, "base.html", data)
return
}
// Redirect back to users page with success message
c.Redirect(http.StatusFound, "/admin/users?success=User created successfully")
}
// ShowTodos displays the todos page
func (h *WebHandler) ShowTodos(c *gin.Context) {
user, _ := middleware.GetCurrentUser(c)
// Get user's todos
var todos []models.Todo
database.DB.
Preload("Context").
Preload("Project").
Where("user_id = ?", user.ID).
Order("created_at DESC").
Find(&todos)
// Get user's contexts for the dropdown
var contexts []models.Context
database.DB.
Where("user_id = ? AND state = ?", user.ID, "active").
Order("position ASC").
Find(&contexts)
data := gin.H{
"Title": "Todos",
"User": user,
"Todos": todos,
"Contexts": contexts,
}
c.Header("Content-Type", "text/html; charset=utf-8")
h.templates.ExecuteTemplate(c.Writer, "base.html", data)
}
// ShowProjects displays the projects page
func (h *WebHandler) ShowProjects(c *gin.Context) {
user, _ := middleware.GetCurrentUser(c)
// Get user's projects
var projects []models.Project
database.DB.
Where("user_id = ?", user.ID).
Order("created_at DESC").
Find(&projects)
data := gin.H{
"Title": "Projects",
"User": user,
"Projects": projects,
}
c.Header("Content-Type", "text/html; charset=utf-8")
h.templates.ExecuteTemplate(c.Writer, "base.html", data)
}
// ShowContexts displays the contexts page
func (h *WebHandler) ShowContexts(c *gin.Context) {
user, _ := middleware.GetCurrentUser(c)
// Get user's contexts
var contexts []models.Context
database.DB.
Where("user_id = ?", user.ID).
Order("position ASC").
Find(&contexts)
data := gin.H{
"Title": "Contexts",
"User": user,
"Contexts": contexts,
}
c.Header("Content-Type", "text/html; charset=utf-8")
h.templates.ExecuteTemplate(c.Writer, "base.html", data)
}
// HandleCreateContext processes context creation form
func (h *WebHandler) HandleCreateContext(c *gin.Context) {
user, _ := middleware.GetCurrentUser(c)
name := c.PostForm("name")
if name == "" {
c.Redirect(http.StatusFound, "/contexts?error=Context name is required")
return
}
// Get the highest position value for proper ordering
var maxPosition int
database.DB.Model(&models.Context{}).
Where("user_id = ?", user.ID).
Select("COALESCE(MAX(position), 0)").
Scan(&maxPosition)
// Create context
context := models.Context{
UserID: user.ID,
Name: name,
State: "active",
Position: maxPosition + 1,
}
if err := database.DB.Create(&context).Error; err != nil {
c.Redirect(http.StatusFound, "/contexts?error="+err.Error())
return
}
// Redirect back to contexts page
c.Redirect(http.StatusFound, "/contexts")
}
// HandleDeleteContext processes context deletion
func (h *WebHandler) HandleDeleteContext(c *gin.Context) {
user, _ := middleware.GetCurrentUser(c)
contextID := c.Param("id")
// Verify context belongs to user
var context models.Context
if err := database.DB.Where("id = ? AND user_id = ?", contextID, user.ID).First(&context).Error; err != nil {
c.Redirect(http.StatusFound, "/contexts?error=Context not found")
return
}
// Delete context
if err := database.DB.Delete(&context).Error; err != nil {
c.Redirect(http.StatusFound, "/contexts?error="+err.Error())
return
}
// Redirect back to contexts page
c.Redirect(http.StatusFound, "/contexts")
}
// HandleCreateTodo processes todo creation form
func (h *WebHandler) HandleCreateTodo(c *gin.Context) {
user, _ := middleware.GetCurrentUser(c)
description := c.PostForm("description")
if description == "" {
c.Redirect(http.StatusFound, "/todos?error=Description is required")
return
}
notes := c.PostForm("notes")
contextIDStr := c.PostForm("context_id")
newContextName := c.PostForm("new_context_name")
dueDateStr := c.PostForm("due_date")
var contextID uint
// Check if user wants to create a new context
if contextIDStr == "__new__" {
// Validate new context name
if newContextName == "" {
c.Redirect(http.StatusFound, "/todos?error=New context name is required")
return
}
// Get the highest position value for proper ordering
var maxPosition int
database.DB.Model(&models.Context{}).
Where("user_id = ?", user.ID).
Select("COALESCE(MAX(position), 0)").
Scan(&maxPosition)
// Create new context
newContext := models.Context{
UserID: user.ID,
Name: newContextName,
State: "active",
Position: maxPosition + 1,
}
if err := database.DB.Create(&newContext).Error; err != nil {
c.Redirect(http.StatusFound, "/todos?error=Failed to create context: "+err.Error())
return
}
contextID = newContext.ID
} else {
// Parse existing context ID (required)
if contextIDStr == "" {
c.Redirect(http.StatusFound, "/todos?error=Context is required")
return
}
if _, err := fmt.Sscanf(contextIDStr, "%d", &contextID); err != nil {
c.Redirect(http.StatusFound, "/todos?error=Invalid context")
return
}
// Verify context exists and belongs to user
var context models.Context
if err := database.DB.Where("id = ? AND user_id = ?", contextID, user.ID).First(&context).Error; err != nil {
c.Redirect(http.StatusFound, "/todos?error=Context not found")
return
}
}
// Create todo
todo := models.Todo{
UserID: user.ID,
ContextID: contextID,
Description: description,
Notes: notes,
State: "active",
}
// Parse and set due date if provided
if dueDateStr != "" {
if dueDate, err := time.Parse("2006-01-02", dueDateStr); err == nil {
todo.DueDate = &dueDate
}
}
if err := database.DB.Create(&todo).Error; err != nil {
c.Redirect(http.StatusFound, "/todos?error="+err.Error())
return
}
// Redirect back to todos page
c.Redirect(http.StatusFound, "/todos")
}
// HandleDeleteTodo processes todo deletion
func (h *WebHandler) HandleDeleteTodo(c *gin.Context) {
user, _ := middleware.GetCurrentUser(c)
todoID := c.Param("id")
// Verify todo belongs to user
var todo models.Todo
if err := database.DB.Where("id = ? AND user_id = ?", todoID, user.ID).First(&todo).Error; err != nil {
c.Redirect(http.StatusFound, "/todos?error=Todo not found")
return
}
// Delete todo
if err := database.DB.Delete(&todo).Error; err != nil {
c.Redirect(http.StatusFound, "/todos?error="+err.Error())
return
}
// Redirect back to todos page
c.Redirect(http.StatusFound, "/todos")
}
// RSS feed structures
type RSSFeed struct {
XMLName xml.Name `xml:"rss"`
Version string `xml:"version,attr"`
Channel RSSChannel
}
type RSSChannel struct {
XMLName xml.Name `xml:"channel"`
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
Language string `xml:"language"`
PubDate string `xml:"pubDate"`
Items []RSSItem
}
type RSSItem struct {
XMLName xml.Name `xml:"item"`
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
PubDate string `xml:"pubDate"`
GUID string `xml:"guid"`
}
// HandleContextFeed generates an RSS feed for todos in a specific context
func (h *WebHandler) HandleContextFeed(c *gin.Context) {
user, _ := middleware.GetCurrentUser(c)
contextID := c.Param("id")
// Verify context belongs to user
var context models.Context
if err := database.DB.Where("id = ? AND user_id = ?", contextID, user.ID).First(&context).Error; err != nil {
c.XML(http.StatusNotFound, gin.H{"error": "Context not found"})
return
}
// Get all todos for this context
var todos []models.Todo
database.DB.
Preload("Project").
Where("user_id = ? AND context_id = ?", user.ID, contextID).
Order("created_at DESC").
Find(&todos)
// Build RSS feed
feed := RSSFeed{
Version: "2.0",
Channel: RSSChannel{
Title: fmt.Sprintf("Tracks - %s Todos", context.Name),
Link: fmt.Sprintf("%s/contexts/%s", c.Request.Host, contextID),
Description: fmt.Sprintf("Todos for context: %s", context.Name),
Language: "en-us",
PubDate: time.Now().Format(time.RFC1123Z),
Items: make([]RSSItem, 0, len(todos)),
},
}
// Add todos as RSS items
for _, todo := range todos {
description := todo.Description
if todo.Notes != "" {
description += "\n\n" + todo.Notes
}
if todo.Project != nil {
description += fmt.Sprintf("\n\nProject: %s", todo.Project.Name)
}
if todo.DueDate != nil {
description += fmt.Sprintf("\n\nDue: %s", todo.DueDate.Format("2006-01-02"))
}
item := RSSItem{
Title: todo.Description,
Link: fmt.Sprintf("%s/todos/%d", c.Request.Host, todo.ID),
Description: description,
PubDate: todo.CreatedAt.Format(time.RFC1123Z),
GUID: fmt.Sprintf("todo-%d", todo.ID),
}
feed.Channel.Items = append(feed.Channel.Items, item)
}
// Return RSS XML
c.Header("Content-Type", "application/rss+xml; charset=utf-8")
xmlData, err := xml.MarshalIndent(feed, "", " ")
if err != nil {
c.XML(http.StatusInternalServerError, gin.H{"error": "Failed to generate RSS feed"})
return
}
c.Data(http.StatusOK, "application/rss+xml; charset=utf-8", append([]byte(xml.Header), xmlData...))
}