tracker: single-binary project tracker with auth, api keys, and json api

Go + SQLite web app, server-rendered, hackerman theme. Projects with
descriptions, searchable notes, todo/doing/done task tracking. Bcrypt
login sessions, hashed api keys, full /api/v1 crud. Deploys on straylight
via configs/tracker.nix (prebuilt binary).
This commit is contained in:
2026-09-18 21:28:40 -07:00
commit c9e74b236d
20 changed files with 2489 additions and 0 deletions
+437
View File
@@ -0,0 +1,437 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"tracker/internal/store"
)
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func apiError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func readJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
dec := json.NewDecoder(r.Body)
if err := dec.Decode(dst); err != nil {
apiError(w, http.StatusBadRequest, "invalid json body: "+err.Error())
return false
}
return true
}
func (s *Server) apiProjectID(w http.ResponseWriter, r *http.Request) (int64, bool) {
return s.apiID(w, r)
}
func (s *Server) apiID(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id < 1 {
apiError(w, http.StatusBadRequest, "invalid id")
return 0, false
}
return id, true
}
func (s *Server) apiNotFound(w http.ResponseWriter, err error) bool {
if errors.Is(err, store.ErrNotFound) {
apiError(w, http.StatusNotFound, "not found")
return true
}
return false
}
// --- projects ---
type projectPayload struct {
Name *string `json:"name"`
Description *string `json:"description"`
}
func (s *Server) handleAPIProjectList(w http.ResponseWriter, r *http.Request) {
projects, err := s.store.Projects()
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
if projects == nil {
projects = []store.Project{}
}
writeJSON(w, http.StatusOK, projects)
}
func (s *Server) handleAPIProjectCreate(w http.ResponseWriter, r *http.Request) {
var p projectPayload
if !readJSON(w, r, &p) {
return
}
if p.Name == nil || strings.TrimSpace(*p.Name) == "" {
apiError(w, http.StatusBadRequest, "name is required")
return
}
desc := ""
if p.Description != nil {
desc = strings.TrimSpace(*p.Description)
}
id, err := s.store.CreateProject(strings.TrimSpace(*p.Name), desc)
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
project, err := s.store.Project(id)
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, project)
}
func (s *Server) handleAPIProjectGet(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiProjectID(w, r)
if !ok {
return
}
project, err := s.store.Project(id)
if s.apiNotFound(w, err) {
return
}
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, project)
}
func (s *Server) handleAPIProjectUpdate(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiProjectID(w, r)
if !ok {
return
}
var p projectPayload
if !readJSON(w, r, &p) {
return
}
project, err := s.store.Project(id)
if s.apiNotFound(w, err) {
return
}
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
if p.Name != nil {
if strings.TrimSpace(*p.Name) == "" {
apiError(w, http.StatusBadRequest, "name cannot be empty")
return
}
project.Name = strings.TrimSpace(*p.Name)
}
if p.Description != nil {
project.Description = strings.TrimSpace(*p.Description)
}
if err := s.store.UpdateProject(id, project.Name, project.Description); err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, project)
}
func (s *Server) handleAPIProjectDelete(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiProjectID(w, r)
if !ok {
return
}
if err := s.store.DeleteProject(id); s.apiNotFound(w, err) {
return
} else if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// --- notes ---
type notePayload struct {
Body *string `json:"body"`
}
func (s *Server) handleAPINoteList(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiProjectID(w, r)
if !ok {
return
}
notes, err := s.store.Notes(id)
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
if notes == nil {
notes = []store.Note{}
}
writeJSON(w, http.StatusOK, notes)
}
func (s *Server) handleAPINoteCreate(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiProjectID(w, r)
if !ok {
return
}
var n notePayload
if !readJSON(w, r, &n) {
return
}
if n.Body == nil || strings.TrimSpace(*n.Body) == "" {
apiError(w, http.StatusBadRequest, "body is required")
return
}
if _, err := s.store.Project(id); s.apiNotFound(w, err) {
return
} else if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
noteID, err := s.store.CreateNote(id, strings.TrimSpace(*n.Body))
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
notes, err := s.store.Notes(id)
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
for _, note := range notes {
if note.ID == noteID {
writeJSON(w, http.StatusCreated, note)
return
}
}
writeJSON(w, http.StatusCreated, map[string]int64{"id": noteID})
}
func (s *Server) handleAPINoteGet(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiID(w, r)
if !ok {
return
}
note, err := s.store.Note(id)
if s.apiNotFound(w, err) {
return
}
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, note)
}
func (s *Server) handleAPINoteUpdate(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiID(w, r)
if !ok {
return
}
var n notePayload
if !readJSON(w, r, &n) {
return
}
if n.Body == nil || strings.TrimSpace(*n.Body) == "" {
apiError(w, http.StatusBadRequest, "body is required")
return
}
if err := s.store.UpdateNote(id, strings.TrimSpace(*n.Body)); s.apiNotFound(w, err) {
return
} else if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
}
func (s *Server) handleAPINoteDelete(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiID(w, r)
if !ok {
return
}
if err := s.store.DeleteNote(id); s.apiNotFound(w, err) {
return
} else if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// --- tasks ---
type taskPayload struct {
Title *string `json:"title"`
Status *string `json:"status"`
}
var validStatuses = map[string]bool{"todo": true, "doing": true, "done": true}
func (s *Server) handleAPITaskList(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiProjectID(w, r)
if !ok {
return
}
tasks, err := s.store.Tasks(id)
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
if tasks == nil {
tasks = []store.Task{}
}
writeJSON(w, http.StatusOK, tasks)
}
func (s *Server) handleAPITaskCreate(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiProjectID(w, r)
if !ok {
return
}
var t taskPayload
if !readJSON(w, r, &t) {
return
}
if t.Title == nil || strings.TrimSpace(*t.Title) == "" {
apiError(w, http.StatusBadRequest, "title is required")
return
}
if t.Status != nil && !validStatuses[*t.Status] {
apiError(w, http.StatusBadRequest, "status must be todo, doing, or done")
return
}
if _, err := s.store.Project(id); s.apiNotFound(w, err) {
return
} else if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
taskID, err := s.store.CreateTask(id, strings.TrimSpace(*t.Title))
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
if t.Status != nil && *t.Status != "todo" {
if err := s.store.SetTaskStatus(taskID, *t.Status); err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
}
tasks, err := s.store.Tasks(id)
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
for _, task := range tasks {
if task.ID == taskID {
writeJSON(w, http.StatusCreated, task)
return
}
}
writeJSON(w, http.StatusCreated, map[string]int64{"id": taskID})
}
func (s *Server) handleAPITaskGet(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiID(w, r)
if !ok {
return
}
task, err := s.store.Task(id)
if s.apiNotFound(w, err) {
return
}
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, task)
}
func (s *Server) handleAPITaskUpdate(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiID(w, r)
if !ok {
return
}
var t taskPayload
if !readJSON(w, r, &t) {
return
}
if t.Status != nil && !validStatuses[*t.Status] {
apiError(w, http.StatusBadRequest, "status must be todo, doing, or done")
return
}
task, err := s.store.Task(id)
if s.apiNotFound(w, err) {
return
}
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
if t.Title != nil {
if strings.TrimSpace(*t.Title) == "" {
apiError(w, http.StatusBadRequest, "title cannot be empty")
return
}
task.Title = strings.TrimSpace(*t.Title)
if err := s.store.UpdateTaskTitle(id, task.Title); err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
}
if t.Status != nil {
task.Status = *t.Status
if err := s.store.SetTaskStatus(id, task.Status); err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
}
writeJSON(w, http.StatusOK, task)
}
func (s *Server) handleAPITaskDelete(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiID(w, r)
if !ok {
return
}
if err := s.store.DeleteTask(id); s.apiNotFound(w, err) {
return
} else if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// --- search ---
func (s *Server) handleAPISearch(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
apiError(w, http.StatusBadRequest, "q parameter is required")
return
}
hits, err := s.store.Search(q)
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
if hits == nil {
hits = []store.Hit{}
}
writeJSON(w, http.StatusOK, hits)
}
+230
View File
@@ -0,0 +1,230 @@
package server
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"net/http"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"tracker/internal/store"
)
const (
sessionCookie = "tracker_session"
sessionLifetime = 30 * 24 * time.Hour
keyPrefix = "trk_"
)
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func randomHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic("crypto/rand unavailable: " + err.Error())
}
return hex.EncodeToString(b)
}
func (s *Server) checkPassword(username, password string) bool {
u, err := s.store.UserByName(username)
if err != nil {
// burn comparable time so missing users aren't distinguishable
bcrypt.CompareHashAndPassword([]byte("$2a$10$7EqJtq98hPqEX7fNZaFWoOhi5B0X0PYmVlSkC0pR0uW9QlY0S3zKe"), []byte(password))
return false
}
return bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil
}
// --- session helpers ---
func (s *Server) sessionUser(r *http.Request) (string, bool) {
c, err := r.Cookie(sessionCookie)
if err != nil || c.Value == "" {
return "", false
}
username, err := s.store.SessionByTokenHash(hashToken(c.Value))
if err != nil {
return "", false
}
return username, true
}
func (s *Server) startSession(w http.ResponseWriter, username string) error {
token := randomHex(32)
expires := time.Now().Add(sessionLifetime).Format("2006-01-02 15:04:05")
if err := s.store.CreateSession(hashToken(token), username, expires); err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: int(sessionLifetime.Seconds()),
})
return nil
}
// --- login / logout ---
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
if _, ok := s.sessionUser(r); ok {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
s.render(w, http.StatusOK, "login.html", map[string]any{
"Title": "login",
})
}
func (s *Server) handleLoginPost(w http.ResponseWriter, r *http.Request) {
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
if !s.checkPassword(username, password) {
time.Sleep(400 * time.Millisecond)
s.render(w, http.StatusUnauthorized, "login.html", map[string]any{
"Title": "login",
"Error": "access denied: unknown handle or bad passphrase",
})
return
}
s.store.DeleteExpiredSessions()
if err := s.startSession(w, username); err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" {
s.store.DeleteSession(hashToken(c.Value))
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: "",
Path: "/",
HttpOnly: true,
MaxAge: -1,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// --- api keys page ---
func (s *Server) handleKeys(w http.ResponseWriter, r *http.Request) {
s.renderKeys(w, r, http.StatusOK, nil, "")
}
func (s *Server) renderKeys(w http.ResponseWriter, r *http.Request, status int, keys []store.APIKey, newKey string) {
if keys == nil {
var err error
keys, err = s.store.APIKeys()
if err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
}
s.render(w, status, "keys.html", map[string]any{
"Title": "api keys",
"Keys": keys,
"NewKey": newKey,
})
}
func (s *Server) handleKeyCreate(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
s.renderKeys(w, r, http.StatusBadRequest, nil, "")
return
}
plain := keyPrefix + randomHex(24)
if _, err := s.store.CreateAPIKey(name, hashToken(plain)); err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
s.renderKeys(w, r, http.StatusOK, nil, plain)
}
func (s *Server) handleKeyDelete(w http.ResponseWriter, r *http.Request) {
id, ok := s.pathID(w, r)
if !ok {
return
}
if err := s.store.DeleteAPIKey(id); err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
http.Redirect(w, r, "/keys", http.StatusSeeOther)
}
// --- credential checks ---
func apiKeyFromRequest(r *http.Request) string {
if key := r.Header.Get("X-API-Key"); key != "" {
return key
}
if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
return strings.TrimPrefix(auth, "Bearer ")
}
return ""
}
func (s *Server) apiAuthorized(r *http.Request) bool {
if key := apiKeyFromRequest(r); key != "" {
k, err := s.store.KeyByHash(hashToken(key))
if err != nil {
return false
}
s.store.TouchKey(k.ID)
return true
}
if user, pass, ok := r.BasicAuth(); ok {
return s.checkPassword(user, pass)
}
if _, ok := s.sessionUser(r); ok {
return true
}
return false
}
// --- middleware ---
func (s *Server) auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
switch {
case strings.HasPrefix(p, "/static/"):
next.ServeHTTP(w, r)
return
case p == "/favicon.ico":
http.NotFound(w, r)
return
case strings.HasPrefix(p, "/api/"):
if s.apiAuthorized(r) {
next.ServeHTTP(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"unauthorized"}`))
return
case p == "/login":
next.ServeHTTP(w, r)
return
}
if _, ok := s.sessionUser(r); ok {
next.ServeHTTP(w, r)
return
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
})
}
+360
View File
@@ -0,0 +1,360 @@
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(),
})
}
+368
View File
@@ -0,0 +1,368 @@
/* ── tracker :: hackerman theme ─────────────────────────────
palette lifted from omarchy/hackerman.nvim
bg #0B0C16 · panel #080911 · line #1a1d2b
fg #ddf7ff · muted #6a6e95 · neon #50f872 · cyan #7cf8f7
─────────────────────────────────────────────────────────── */
:root {
--bg: #0B0C16;
--bg-deep: #080911;
--bg-line: #1a1d2b;
--fg: #ddf7ff;
--muted: #6a6e95;
--neon: #50f872;
--cyan: #7cf8f7;
--yellow: #7cf8d4;
--glow: 0 0 8px rgba(80, 248, 114, .55);
--glow-cyan: 0 0 8px rgba(124, 248, 247, .45);
}
* { box-sizing: border-box; }
html { color-scheme: dark; }
body {
margin: 0;
background: radial-gradient(ellipse at 50% -20%, #101426 0%, var(--bg) 55%, #05060c 100%);
color: var(--fg);
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", ui-monospace, "SF Mono", Menlo, monospace;
font-size: 15px;
line-height: 1.55;
min-height: 100vh;
}
/* crt scanlines + flicker */
.scanlines {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 999;
background:
repeating-linear-gradient(0deg, rgba(0,0,0,.18) 0 1px, transparent 1px 3px);
mix-blend-mode: multiply;
animation: flicker 4s infinite;
}
@keyframes flicker {
0%, 100% { opacity: .9; }
92% { opacity: .9; }
93% { opacity: .55; }
94% { opacity: .9; }
97% { opacity: .75; }
98% { opacity: .9; }
}
::selection { background: var(--bg-line); color: var(--neon); }
a { color: var(--cyan); text-decoration: none; }
a:hover { text-shadow: var(--glow-cyan); }
/* ── topbar ── */
.topbar {
display: flex;
align-items: center;
gap: 1.5rem;
padding: .8rem 1.5rem;
border-bottom: 1px solid var(--bg-line);
background: rgba(8, 9, 17, .85);
position: sticky;
top: 0;
backdrop-filter: blur(6px);
z-index: 10;
}
.logo {
font-weight: 700;
color: var(--neon);
text-shadow: var(--glow);
letter-spacing: .05em;
white-space: nowrap;
}
.cursor { animation: blink 1.1s steps(1) infinite; }
@keyframes blink { 50% { opacity: 0; } }
.searchbar { flex: 1; }
.searchbar input {
width: 100%;
max-width: 420px;
}
/* ── layout ── */
.wrap {
max-width: 860px;
margin: 2rem auto 4rem;
padding: 0 1.25rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.panel {
background: linear-gradient(180deg, var(--bg-deep), rgba(11,12,22,.6));
border: 1px solid var(--bg-line);
border-radius: 6px;
padding: 1.25rem 1.5rem 1.5rem;
box-shadow: 0 0 0 1px rgba(80,248,114,.04), 0 12px 40px rgba(0,0,0,.5);
position: relative;
}
.panel::before {
content: "";
position: absolute;
top: -1px; left: 1.5rem;
width: 4rem; height: 1px;
background: var(--neon);
box-shadow: var(--glow);
}
h1, h2 {
font-size: 1rem;
text-transform: lowercase;
letter-spacing: .08em;
margin: 0 0 1rem;
}
.glow { color: var(--neon); text-shadow: var(--glow); }
.muted { color: var(--muted); }
.description {
white-space: pre-wrap;
margin: 0;
color: var(--fg);
}
.row { display: flex; align-items: center; gap: .75rem; }
.spread { justify-content: space-between; }
.push { margin-left: auto; }
/* ── stats ── */
.stats {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.stat {
flex: 1;
min-width: 90px;
border: 1px solid var(--bg-line);
border-radius: 6px;
background: var(--bg-deep);
padding: .75rem 1rem;
text-align: center;
}
.stat .num {
display: block;
font-size: 1.6rem;
font-weight: 700;
color: var(--neon);
text-shadow: var(--glow);
}
.stat.doing .num { color: var(--yellow); text-shadow: 0 0 8px rgba(124,248,212,.5); animation: pulse 2s infinite; }
.stat.done .num { color: var(--cyan); text-shadow: var(--glow-cyan); }
.stat .label { color: var(--muted); font-size: .75rem; text-transform: uppercase; letter-spacing: .15em; }
@keyframes pulse { 50% { opacity: .55; } }
/* ── forms ── */
input, textarea {
width: 100%;
background: #05060c;
border: 1px solid var(--bg-line);
border-radius: 4px;
color: var(--fg);
font: inherit;
padding: .55rem .8rem;
outline: none;
transition: border-color .15s, box-shadow .15s;
}
input:focus, textarea:focus {
border-color: var(--neon);
box-shadow: var(--glow), inset 0 0 12px rgba(80,248,114,.06);
}
input::placeholder, textarea::placeholder { color: #3d4160; }
textarea { resize: vertical; }
.stack { display: flex; flex-direction: column; gap: .75rem; }
.inline-form {
display: flex;
gap: .75rem;
margin-bottom: 1.25rem;
}
.inline-form input { flex: 1; }
.big-search input { font-size: 1.1rem; }
label {
display: flex;
flex-direction: column;
gap: .4rem;
color: var(--muted);
font-size: .8rem;
text-transform: uppercase;
letter-spacing: .1em;
}
/* ── buttons ── */
.btn {
display: inline-block;
background: transparent;
border: 1px solid var(--bg-line);
border-radius: 4px;
color: var(--cyan);
font: inherit;
font-size: .85rem;
padding: .45rem 1rem;
cursor: pointer;
transition: all .15s;
white-space: nowrap;
}
.btn:hover {
border-color: var(--cyan);
box-shadow: var(--glow-cyan);
text-shadow: var(--glow-cyan);
}
.btn.primary { color: var(--neon); border-color: rgba(80,248,114,.35); }
.btn.primary:hover { border-color: var(--neon); box-shadow: var(--glow); text-shadow: var(--glow); }
.btn.danger { color: #ff5c8a; border-color: rgba(255,92,138,.35); }
.btn.danger:hover { border-color: #ff5c8a; box-shadow: 0 0 8px rgba(255,92,138,.5); }
.btn.ghost { border: none; padding: .2rem .5rem; color: var(--muted); font-size: .8rem; }
.btn.ghost:hover { color: #ff5c8a; text-shadow: none; box-shadow: none; }
/* ── project list ── */
.project-list { list-style: none; margin: 0; padding: 0; }
.project-list li {
padding: .8rem .25rem;
border-bottom: 1px dashed var(--bg-line);
}
.project-list li:last-child { border-bottom: none; }
.project-link {
color: var(--fg);
font-weight: 700;
}
.project-link:hover { color: var(--neon); text-shadow: var(--glow); }
.project-link .arrow { color: var(--neon); }
.project-list p { margin: .2rem 0 0; font-size: .85rem; }
/* ── tasks ── */
.task-list { list-style: none; margin: 0; padding: 0; }
.task {
display: flex;
align-items: center;
gap: .75rem;
padding: .45rem .25rem;
border-bottom: 1px dashed var(--bg-line);
}
.task:last-child { border-bottom: none; }
.task form { margin: 0; }
.task-title { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.task-title.done { color: var(--muted); text-decoration: line-through; }
.status {
border: 1px solid var(--bg-line);
border-radius: 3px;
background: transparent;
font: inherit;
font-size: .72rem;
letter-spacing: .08em;
padding: .25rem .55rem;
cursor: pointer;
transition: all .15s;
min-width: 5.2rem;
text-align: left;
}
.status.todo { color: var(--muted); }
.status.doing { color: var(--yellow); border-color: rgba(124,248,212,.4); text-shadow: 0 0 6px rgba(124,248,212,.5); animation: pulse 2s infinite; }
.status.done { color: var(--neon); border-color: rgba(80,248,114,.4); text-shadow: 0 0 6px rgba(80,248,114,.5); }
.status:hover { border-color: currentColor; }
/* ── notes ── */
.note-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .9rem; }
.note {
border: 1px solid var(--bg-line);
border-left: 2px solid var(--cyan);
border-radius: 4px;
background: #05060c;
padding: .7rem 1rem;
}
.note .row { margin-bottom: .2rem; }
.note-body {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* ── search hits ── */
.hit-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1rem; }
.hit {
border-bottom: 1px dashed var(--bg-line);
padding-bottom: 1rem;
}
.hit:last-child { border-bottom: none; padding-bottom: 0; }
.hit .badge {
font-size: .68rem;
text-transform: uppercase;
letter-spacing: .12em;
border: 1px solid;
border-radius: 3px;
padding: .1rem .5rem;
margin-right: .6rem;
}
.badge.project { color: var(--neon); border-color: rgba(80,248,114,.4); }
.badge.note { color: var(--cyan); border-color: rgba(124,248,247,.4); }
.badge.task { color: var(--yellow); border-color: rgba(124,248,212,.4); }
.hit-title { color: var(--fg); font-weight: 700; }
.hit-title:hover { color: var(--neon); text-shadow: var(--glow); }
.snippet { margin: .3rem 0 0; font-size: .85rem; }
/* ── auth / keys ── */
.nav { display: flex; align-items: center; gap: .9rem; }
.nav a { color: var(--muted); font-size: .85rem; }
.nav a:hover { color: var(--neon); }
.nav form { margin: 0; }
.login-panel { max-width: 420px; margin: 15vh auto 0; }
.error-msg {
color: #ff5c8a;
border: 1px solid rgba(255,92,138,.4);
border-left: 2px solid #ff5c8a;
border-radius: 4px;
background: rgba(255,92,138,.06);
padding: .6rem 1rem;
margin: 0 0 1rem;
font-size: .85rem;
}
.keybox {
background: #05060c;
border: 1px solid rgba(80,248,114,.4);
box-shadow: var(--glow), inset 0 0 20px rgba(80,248,114,.05);
border-radius: 4px;
padding: .9rem 1.2rem;
color: var(--neon);
text-shadow: var(--glow);
overflow-wrap: anywhere;
user-select: all;
}
.key-list { list-style: none; margin: 1rem 0 0; padding: 0; }
.key-row {
display: flex;
align-items: center;
gap: 1rem;
padding: .55rem .25rem;
border-bottom: 1px dashed var(--bg-line);
font-size: .88rem;
}
.key-row:last-child { border-bottom: none; }
.key-row form { margin: 0; }
.key-name { color: var(--cyan); text-shadow: var(--glow-cyan); }
code {
background: #05060c;
border: 1px solid var(--bg-line);
border-radius: 3px;
padding: .05rem .35rem;
font-size: .85em;
color: var(--yellow);
}
/* ── footer ── */
.foot {
text-align: center;
color: var(--muted);
font-size: .75rem;
padding: 1rem 0 2rem;
letter-spacing: .1em;
}
+19
View File
@@ -0,0 +1,19 @@
{{template "header" .}}
<section class="panel">
<h1 class="glow">// edit: {{.Project.Name}}</h1>
<form method="post" action="/projects/{{.Project.ID}}/update" class="stack">
<label>name
<input type="text" name="name" value="{{.Project.Name}}" required maxlength="120">
</label>
<label>description
<textarea name="description" rows="6">{{.Project.Description}}</textarea>
</label>
<div class="row">
<button type="submit" class="btn primary">save ▸</button>
<a class="btn" href="/projects/{{.Project.ID}}">cancel</a>
</div>
</form>
</section>
{{template "footer" .}}
+9
View File
@@ -0,0 +1,9 @@
{{template "header" .}}
<section class="panel">
<h1 class="glow">// error {{.Status}}</h1>
<p class="description">{{.Message}}</p>
<p><a class="btn" href="/">◂ back to base</a></p>
</section>
{{template "footer" .}}
+35
View File
@@ -0,0 +1,35 @@
{{template "header" .}}
<section class="stats">
<div class="stat"><span class="num">{{.Stats.Projects}}</span><span class="label">projects</span></div>
<div class="stat todo"><span class="num">{{.Stats.Tasks.Todo}}</span><span class="label">todo</span></div>
<div class="stat doing"><span class="num">{{.Stats.Tasks.Doing}}</span><span class="label">doing</span></div>
<div class="stat done"><span class="num">{{.Stats.Tasks.Done}}</span><span class="label">done</span></div>
<div class="stat"><span class="num">{{.Stats.Notes}}</span><span class="label">notes</span></div>
</section>
<section class="panel">
<h1 class="glow">// new project</h1>
<form method="post" action="/projects" class="stack">
<input type="text" name="name" placeholder="project name" required maxlength="120">
<textarea name="description" placeholder="description…" rows="3"></textarea>
<button type="submit" class="btn primary">init ▸</button>
</form>
</section>
<section class="panel">
<h1 class="glow">// projects</h1>
{{if not .Projects}}
<p class="muted">no projects on the grid. create one above ▲</p>
{{end}}
<ul class="project-list">
{{range .Projects}}
<li>
<a class="project-link" href="/projects/{{.ID}}">{{.Name}}<span class="arrow"></span></a>
{{if .Description}}<p class="muted">{{.Description}}</p>{{end}}
</li>
{{end}}
</ul>
</section>
{{template "footer" .}}
+33
View File
@@ -0,0 +1,33 @@
{{template "header" .}}
{{if .NewKey}}
<section class="panel">
<h1 class="glow">// key generated</h1>
<p>copy it now — it is stored hashed and will not be shown again:</p>
<pre class="keybox">{{.NewKey}}</pre>
</section>
{{end}}
<section class="panel">
<h1 class="glow">// api keys</h1>
<p class="muted">keys grant full API access via <code>X-API-Key</code> or <code>Authorization: Bearer</code>.</p>
<form method="post" action="/keys/create" class="inline-form">
<input type="text" name="name" placeholder="key name (e.g. laptop-cli)" required maxlength="60">
<button type="submit" class="btn primary">generate ▸</button>
</form>
{{if not .Keys}}<p class="muted">no keys issued.</p>{{end}}
<ul class="key-list">
{{range .Keys}}
<li class="key-row">
<span class="key-name">{{.Name}}</span>
<span class="muted">created {{dateFmt .CreatedAt}}</span>
<span class="muted push">{{if .LastUsed.Valid}}last used {{dateFmt .LastUsed.String}}{{else}}never used{{end}}</span>
<form method="post" action="/keys/{{.ID}}/delete" onsubmit="return confirm('revoke this key?')">
<button type="submit" class="btn ghost">revoke ✕</button>
</form>
</li>
{{end}}
</ul>
</section>
{{template "footer" .}}
+17
View File
@@ -0,0 +1,17 @@
{{template "header" .}}
<section class="panel login-panel">
<h1 class="glow">// access</h1>
{{if .Error}}<p class="error-msg">{{.Error}}</p>{{end}}
<form method="post" action="/login" class="stack">
<label>handle
<input type="text" name="username" required autofocus autocomplete="username">
</label>
<label>passphrase
<input type="password" name="password" required autocomplete="current-password">
</label>
<button type="submit" class="btn primary">jack in ▸</button>
</form>
</section>
{{template "footer" .}}
+29
View File
@@ -0,0 +1,29 @@
{{define "header"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} ∷ TRACKER</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="scanlines" aria-hidden="true"></div>
<nav class="topbar">
<a class="logo" href="/"> tracker<span class="cursor"></span></a>
<form class="searchbar" action="/search" method="get">
<input type="search" name="q" placeholder="grep the grid…" value="" autocomplete="off">
</form>
<div class="nav">
<a href="/keys">keys</a>
<form method="post" action="/logout"><button type="submit" class="btn ghost">logout</button></form>
</div>
</nav>
<main class="wrap">
{{end}}
{{define "footer"}}
</main>
<footer class="foot">// signal intact · {{.Title}}</footer>
</body>
</html>
{{end}}
+61
View File
@@ -0,0 +1,61 @@
{{template "header" .}}
<section class="panel">
<div class="row spread">
<h1 class="glow">{{.Project.Name}}</h1>
<div class="row">
<a class="btn" href="/projects/{{.Project.ID}}/edit">edit</a>
<form method="post" action="/projects/{{.Project.ID}}/delete" onsubmit="return confirm('delete project and all its data?')">
<button type="submit" class="btn danger">rm -rf</button>
</form>
</div>
</div>
{{if .Project.Description}}<p class="description">{{.Project.Description}}</p>
{{else}}<p class="muted">no description.</p>{{end}}
</section>
<section class="panel">
<h2 class="glow">// tasks</h2>
<form method="post" action="/projects/{{.Project.ID}}/tasks" class="inline-form">
<input type="text" name="title" placeholder="new task…" required maxlength="240">
<button type="submit" class="btn primary">add ▸</button>
</form>
{{if not .Tasks}}<p class="muted">no tasks queued.</p>{{end}}
<ul class="task-list">
{{range .Tasks}}
<li class="task">
<form method="post" action="/tasks/{{.ID}}/cycle">
<button type="submit" class="status {{.Status}}" title="cycle status">{{statusLabel .Status}}</button>
</form>
<span class="task-title {{.Status}}">{{.Title}}</span>
<form method="post" action="/tasks/{{.ID}}/delete" class="push">
<button type="submit" class="btn ghost" title="delete"></button>
</form>
</li>
{{end}}
</ul>
</section>
<section class="panel">
<h2 class="glow">// notes</h2>
<form method="post" action="/projects/{{.Project.ID}}/notes" class="stack">
<textarea name="body" placeholder="log an entry…" rows="3" required></textarea>
<div class="row"><button type="submit" class="btn primary">log ▸</button></div>
</form>
{{if not .Notes}}<p class="muted">no entries logged.</p>{{end}}
<ul class="note-list">
{{range .Notes}}
<li class="note">
<div class="row spread">
<span class="muted">[{{dateFmt .CreatedAt}}]</span>
<form method="post" action="/notes/{{.ID}}/delete">
<button type="submit" class="btn ghost" title="delete"></button>
</form>
</div>
<p class="note-body">{{.Body}}</p>
</li>
{{end}}
</ul>
</section>
{{template "footer" .}}
+27
View File
@@ -0,0 +1,27 @@
{{template "header" .}}
<section class="panel">
<h1 class="glow">// search</h1>
<form action="/search" method="get" class="inline-form big-search">
<input type="search" name="q" value="{{.Query}}" placeholder="grep the grid…" autofocus autocomplete="off">
<button type="submit" class="btn primary">run ▸</button>
</form>
</section>
{{if .Query}}
<section class="panel">
<h2 class="muted">{{len .Hits}} hit(s) for “{{.Query}}”</h2>
{{if not .Hits}}<p class="muted">nothing found in the grid.</p>{{end}}
<ul class="hit-list">
{{range .Hits}}
<li class="hit">
<span class="badge {{.Kind}}">{{.Kind}}</span>
<a href="/projects/{{.ProjectID}}" class="hit-title">{{.Title}}</a>
<p class="muted snippet">{{.Snippet}}</p>
</li>
{{end}}
</ul>
</section>
{{end}}
{{template "footer" .}}