mirror of
https://github.com/TracksApp/tracks.git
synced 2025-12-16 15:20:13 +01:00
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.
522 lines
14 KiB
Go
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...))
|
|
}
|