package server import ( "embed" "encoding/json" "errors" "html/template" "log" "net/http" "net/url" "strconv" "strings" "time" "tracker/internal/store" ) //go:embed all:templates var templateFS embed.FS type Server struct { store *store.Store tmpl *template.Template } func New(st *store.Store) (*Server, error) { tmpl, err := template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html") if err != nil { return nil, err } return &Server{store: st, tmpl: tmpl}, nil } var funcMap = template.FuncMap{ "statusLabel": func(s string) string { switch s { case "doing": return "▶ DOING" case "done": return "✓ DONE" default: return "○ TODO" } }, "dateFmt": func(s string) string { t, err := time.Parse("2006-01-02 15:04:05", s) if err != nil { return s } return t.Format("2006-01-02 15:04") }, "json": func(v any) template.JS { b, _ := json.Marshal(v) return template.JS(b) }, } func (s *Server) Routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /static/", s.serveStatic) mux.HandleFunc("GET /{$}", s.handleHome) mux.HandleFunc("GET /search", s.handleSearch) mux.HandleFunc("GET /login", s.handleLoginPage) mux.HandleFunc("POST /login", s.handleLoginPost) mux.HandleFunc("POST /logout", s.handleLogout) mux.HandleFunc("GET /keys", s.handleKeys) mux.HandleFunc("POST /keys/create", s.handleKeyCreate) mux.HandleFunc("POST /keys/{id}/delete", s.handleKeyDelete) mux.HandleFunc("POST /projects", s.handleCreateProject) mux.HandleFunc("GET /projects/{id}", s.handleProject) mux.HandleFunc("GET /projects/{id}/edit", s.handleProjectEdit) mux.HandleFunc("POST /projects/{id}/update", s.handleProjectUpdate) mux.HandleFunc("POST /projects/{id}/delete", s.handleProjectDelete) mux.HandleFunc("POST /projects/{id}/notes", s.handleCreateNote) mux.HandleFunc("POST /notes/{id}/delete", s.handleDeleteNote) mux.HandleFunc("POST /projects/{id}/tasks", s.handleCreateTask) mux.HandleFunc("POST /tasks/{id}/cycle", s.handleCycleTask) mux.HandleFunc("POST /tasks/{id}/delete", s.handleDeleteTask) // json api (behind api-key / basic auth / session) mux.HandleFunc("GET /api/v1/projects", s.handleAPIProjectList) mux.HandleFunc("POST /api/v1/projects", s.handleAPIProjectCreate) mux.HandleFunc("GET /api/v1/projects/{id}", s.handleAPIProjectGet) mux.HandleFunc("PATCH /api/v1/projects/{id}", s.handleAPIProjectUpdate) mux.HandleFunc("PUT /api/v1/projects/{id}", s.handleAPIProjectUpdate) mux.HandleFunc("DELETE /api/v1/projects/{id}", s.handleAPIProjectDelete) mux.HandleFunc("GET /api/v1/projects/{id}/notes", s.handleAPINoteList) mux.HandleFunc("POST /api/v1/projects/{id}/notes", s.handleAPINoteCreate) mux.HandleFunc("GET /api/v1/notes/{id}", s.handleAPINoteGet) mux.HandleFunc("PATCH /api/v1/notes/{id}", s.handleAPINoteUpdate) mux.HandleFunc("PUT /api/v1/notes/{id}", s.handleAPINoteUpdate) mux.HandleFunc("DELETE /api/v1/notes/{id}", s.handleAPINoteDelete) mux.HandleFunc("GET /api/v1/projects/{id}/tasks", s.handleAPITaskList) mux.HandleFunc("POST /api/v1/projects/{id}/tasks", s.handleAPITaskCreate) mux.HandleFunc("GET /api/v1/tasks/{id}", s.handleAPITaskGet) mux.HandleFunc("PATCH /api/v1/tasks/{id}", s.handleAPITaskUpdate) mux.HandleFunc("PUT /api/v1/tasks/{id}", s.handleAPITaskUpdate) mux.HandleFunc("DELETE /api/v1/tasks/{id}", s.handleAPITaskDelete) mux.HandleFunc("GET /api/v1/search", s.handleAPISearch) return s.auth(mux) } // --- static --- //go:embed static var staticFS embed.FS func (s *Server) serveStatic(w http.ResponseWriter, r *http.Request) { http.FileServerFS(staticFS).ServeHTTP(w, r) } // --- pages --- func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { projects, err := s.store.Projects() if err != nil { s.error(w, http.StatusInternalServerError, err) return } stats, err := s.store.Stats() if err != nil { s.error(w, http.StatusInternalServerError, err) return } s.render(w, http.StatusOK, "home.html", map[string]any{ "Title": "projects", "Projects": projects, "Stats": stats, }) } func (s *Server) handleProject(w http.ResponseWriter, r *http.Request) { id, ok := s.pathID(w, r) if !ok { return } project, err := s.store.Project(id) if errors.Is(err, store.ErrNotFound) { s.error(w, http.StatusNotFound, errors.New("project not found")) return } if err != nil { s.error(w, http.StatusInternalServerError, err) return } tasks, err := s.store.Tasks(id) if err != nil { s.error(w, http.StatusInternalServerError, err) return } notes, err := s.store.Notes(id) if err != nil { s.error(w, http.StatusInternalServerError, err) return } s.render(w, http.StatusOK, "project.html", map[string]any{ "Title": project.Name, "Project": project, "Tasks": tasks, "Notes": notes, }) } func (s *Server) handleProjectEdit(w http.ResponseWriter, r *http.Request) { id, ok := s.pathID(w, r) if !ok { return } project, err := s.store.Project(id) if errors.Is(err, store.ErrNotFound) { s.error(w, http.StatusNotFound, errors.New("project not found")) return } if err != nil { s.error(w, http.StatusInternalServerError, err) return } s.render(w, http.StatusOK, "edit.html", map[string]any{ "Title": "edit: " + project.Name, "Project": project, }) } func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { q := strings.TrimSpace(r.URL.Query().Get("q")) var hits []store.Hit if q != "" { var err error hits, err = s.store.Search(q) if err != nil { s.error(w, http.StatusInternalServerError, err) return } } s.render(w, http.StatusOK, "search.html", map[string]any{ "Title": "search", "Query": q, "Hits": hits, }) } // --- actions --- func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) { name, description := r.FormValue("name"), r.FormValue("description") if strings.TrimSpace(name) == "" { s.redirectBack(w, r, "/") return } id, err := s.store.CreateProject(strings.TrimSpace(name), strings.TrimSpace(description)) if err != nil { s.error(w, http.StatusInternalServerError, err) return } http.Redirect(w, r, projectURL(id), http.StatusSeeOther) } func (s *Server) handleProjectUpdate(w http.ResponseWriter, r *http.Request) { id, ok := s.pathID(w, r) if !ok { return } name, description := r.FormValue("name"), r.FormValue("description") if strings.TrimSpace(name) == "" { http.Redirect(w, r, projectURL(id)+"/edit", http.StatusSeeOther) return } if err := s.store.UpdateProject(id, strings.TrimSpace(name), strings.TrimSpace(description)); err != nil { s.error(w, http.StatusInternalServerError, err) return } http.Redirect(w, r, projectURL(id), http.StatusSeeOther) } func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) { id, ok := s.pathID(w, r) if !ok { return } if err := s.store.DeleteProject(id); err != nil { s.error(w, http.StatusInternalServerError, err) return } http.Redirect(w, r, "/", http.StatusSeeOther) } func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) { id, ok := s.pathID(w, r) if !ok { return } body := strings.TrimSpace(r.FormValue("body")) if body != "" { if _, err := s.store.CreateNote(id, body); err != nil { s.error(w, http.StatusInternalServerError, err) return } } http.Redirect(w, r, projectURL(id), http.StatusSeeOther) } func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) { id, ok := s.pathID(w, r) if !ok { return } if err := s.store.DeleteNote(id); err != nil { s.error(w, http.StatusInternalServerError, err) return } s.redirectBack(w, r, "/") } func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) { id, ok := s.pathID(w, r) if !ok { return } title := strings.TrimSpace(r.FormValue("title")) if title != "" { if _, err := s.store.CreateTask(id, title); err != nil { s.error(w, http.StatusInternalServerError, err) return } } http.Redirect(w, r, projectURL(id), http.StatusSeeOther) } func (s *Server) handleCycleTask(w http.ResponseWriter, r *http.Request) { id, ok := s.pathID(w, r) if !ok { return } if err := s.store.CycleTask(id); err != nil { s.error(w, http.StatusInternalServerError, err) return } s.redirectBack(w, r, "/") } func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) { id, ok := s.pathID(w, r) if !ok { return } if err := s.store.DeleteTask(id); err != nil { s.error(w, http.StatusInternalServerError, err) return } s.redirectBack(w, r, "/") } // --- plumbing --- func (s *Server) pathID(w http.ResponseWriter, r *http.Request) (int64, bool) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || id < 1 { s.error(w, http.StatusNotFound, errors.New("bad id")) return 0, false } return id, true } func projectURL(id int64) string { return "/projects/" + strconv.FormatInt(id, 10) } // redirectBack returns the user to where they came from (Referer) so task // toggles from the search page land back on the search page. func (s *Server) redirectBack(w http.ResponseWriter, r *http.Request, fallback string) { if ref := r.Referer(); ref != "" { if u, err := url.Parse(ref); err == nil && u.Path != "" { http.Redirect(w, r, u.Path, http.StatusSeeOther) return } } http.Redirect(w, r, fallback, http.StatusSeeOther) } func (s *Server) render(w http.ResponseWriter, status int, name string, data any) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil { log.Printf("template %s: %v", name, err) } } func (s *Server) error(w http.ResponseWriter, status int, err error) { log.Printf("%d: %v", status, err) s.render(w, status, "error.html", map[string]any{ "Title": "error", "Status": status, "Message": err.Error(), }) }