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:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" .}}
|
||||
@@ -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" .}}
|
||||
@@ -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" .}}
|
||||
@@ -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" .}}
|
||||
@@ -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" .}}
|
||||
@@ -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}}
|
||||
@@ -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" .}}
|
||||
@@ -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" .}}
|
||||
Reference in New Issue
Block a user