diff --git a/cmd/tracks/main.go b/cmd/tracks/main.go
index 34815036..9d7d3d53 100644
--- a/cmd/tracks/main.go
+++ b/cmd/tracks/main.go
@@ -1,8 +1,10 @@
package main
import (
+ "embed"
"flag"
"fmt"
+ "html/template"
"log"
"github.com/TracksApp/tracks/internal/config"
@@ -13,6 +15,9 @@ import (
"github.com/gin-gonic/gin"
)
+//go:embed web/templates/*.html
+var templateFS embed.FS
+
func main() {
// Parse command line flags
port := flag.Int("port", 0, "Port to run the server on (overrides SERVER_PORT env var)")
@@ -71,6 +76,9 @@ func main() {
}
func setupRoutes(router *gin.Engine, cfg *config.Config) {
+ // Parse embedded templates
+ tmpl := template.Must(template.ParseFS(templateFS, "web/templates/*.html"))
+
// Initialize services
authService := services.NewAuthService(cfg.Auth.JWTSecret)
todoService := services.NewTodoService()
@@ -82,6 +90,28 @@ func setupRoutes(router *gin.Engine, cfg *config.Config) {
todoHandler := handlers.NewTodoHandler(todoService)
projectHandler := handlers.NewProjectHandler(projectService)
contextHandler := handlers.NewContextHandler(contextService)
+ webHandler := handlers.NewWebHandler(authService, tmpl)
+
+ // Web UI routes (public)
+ router.GET("/login", webHandler.ShowLogin)
+ router.POST("/login", webHandler.HandleLogin)
+ router.GET("/logout", webHandler.HandleLogout)
+
+ // Web UI routes (protected)
+ webProtected := router.Group("")
+ webProtected.Use(middleware.AuthMiddleware(cfg.Auth.JWTSecret))
+ {
+ webProtected.GET("/", webHandler.ShowDashboard)
+ webProtected.GET("/dashboard", webHandler.ShowDashboard)
+
+ // Admin web routes
+ webAdmin := webProtected.Group("/admin")
+ webAdmin.Use(middleware.AdminMiddleware())
+ {
+ webAdmin.GET("/users", webHandler.ShowAdminUsers)
+ webAdmin.POST("/users", webHandler.HandleCreateUser)
+ }
+ }
// Public routes
api := router.Group("/api")
diff --git a/cmd/tracks/web/templates/admin_users.html b/cmd/tracks/web/templates/admin_users.html
new file mode 100644
index 00000000..d53f8b78
--- /dev/null
+++ b/cmd/tracks/web/templates/admin_users.html
@@ -0,0 +1,218 @@
+{{define "content"}}
+
+
+
+
+
+
+
+
+ | ID |
+ Username |
+ Name |
+ Role |
+ Created |
+ Actions |
+
+
+
+ {{range .Users}}
+
+ | {{.ID}} |
+ {{.Login}} |
+ {{.FirstName}} {{.LastName}} |
+
+ {{if .IsAdmin}}
+ ADMIN
+ {{else}}
+ User
+ {{end}}
+ |
+ {{.CreatedAt.Format "2006-01-02"}} |
+
+
+
+ {{if ne .Login "admin"}}
+
+ {{end}}
+
+ |
+
+ {{end}}
+
+
+
+
+
+
+
+
+{{end}}
diff --git a/cmd/tracks/web/templates/base.html b/cmd/tracks/web/templates/base.html
new file mode 100644
index 00000000..18a6608c
--- /dev/null
+++ b/cmd/tracks/web/templates/base.html
@@ -0,0 +1,289 @@
+
+
+
+
+
+ {{.Title}} - Tracks
+
+
+
+ {{if .User}}
+
+ {{end}}
+
+
+ {{if .Error}}
+
{{.Error}}
+ {{end}}
+ {{if .Success}}
+
{{.Success}}
+ {{end}}
+
+ {{template "content" .}}
+
+
+
+
+
diff --git a/cmd/tracks/web/templates/dashboard.html b/cmd/tracks/web/templates/dashboard.html
new file mode 100644
index 00000000..fde5bfcd
--- /dev/null
+++ b/cmd/tracks/web/templates/dashboard.html
@@ -0,0 +1,187 @@
+{{define "content"}}
+
+
+
+
Welcome back, {{.User.FirstName}}! 👋
+
Here's your productivity overview
+
+
+
+
+
{{.Stats.ActiveTodos}}
+
Active Todos
+
+
+
{{.Stats.ActiveProjects}}
+
Active Projects
+
+
+
{{.Stats.ActiveContexts}}
+
Active Contexts
+
+
+
{{.Stats.CompletedToday}}
+
Completed Today
+
+
+
+
+
+
+
Recent Todos
+ {{if .RecentTodos}}
+ {{range .RecentTodos}}
+
+
+
{{.Description}}
+
+ {{if .Context}}{{.Context.Name}}{{end}}
+ {{if .Project}} • {{.Project.Name}}{{end}}
+
+
+
{{.State}}
+
+ {{end}}
+ {{else}}
+
+
📭
+
No todos yet. Create your first one to get started!
+
+ {{end}}
+
+{{end}}
diff --git a/cmd/tracks/web/templates/login.html b/cmd/tracks/web/templates/login.html
new file mode 100644
index 00000000..294d4baa
--- /dev/null
+++ b/cmd/tracks/web/templates/login.html
@@ -0,0 +1,83 @@
+{{define "content"}}
+
+
+
+
+
+
+
+
+ {{if .FirstTime}}
+
+ First time? Default login is:
+ Username: admin
+ Password: admin
+
+ {{end}}
+
+
+
+
+{{end}}
diff --git a/internal/handlers/web_handler.go b/internal/handlers/web_handler.go
new file mode 100644
index 00000000..ee2daed3
--- /dev/null
+++ b/internal/handlers/web_handler.go
@@ -0,0 +1,185 @@
+package handlers
+
+import (
+ "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")
+}