Add global web nav with project groups and tree sidebar.

Restructure the UI into top-level tabs and a scrollable project tree, and persist optional project groups so demos and settings can organize many projects.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-27 22:43:03 +01:00
co-authored by Cursor
parent 74066649e4
commit 472a2fa72e
13 changed files with 898 additions and 296 deletions
+60
View File
@@ -0,0 +1,60 @@
package server
import (
"net/http"
)
func (s *Server) handleListGroups(w http.ResponseWriter, r *http.Request) {
groups, err := s.DB.ListProjectGroups()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, groups)
}
func (s *Server) handleCreateGroup(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
g, err := s.DB.CreateProjectGroup(body.Name)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, g)
}
func (s *Server) handlePatchGroup(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var body struct {
Name string `json:"name"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if err := s.DB.RenameProjectGroup(id, body.Name); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
g, err := s.DB.GetProjectGroup(id)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, g)
}
func (s *Server) handleDeleteGroup(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.DB.DeleteProjectGroup(id); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
+26 -5
View File
@@ -20,7 +20,8 @@ func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
u, _ := s.currentUser(r)
var body struct {
Name string `json:"name"`
Name string `json:"name"`
GroupID string `json:"groupId"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
@@ -37,6 +38,13 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
return
}
_ = s.DB.SetMember(p.ID, u.ID, sqlite.RoleOwner)
if gid := strings.TrimSpace(body.GroupID); gid != "" {
if err := s.Projects.SetGroup(p.ID, gid); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
p.GroupID = gid
}
writeJSON(w, http.StatusCreated, p)
}
@@ -61,17 +69,30 @@ func (s *Server) handlePatchProject(w http.ResponseWriter, r *http.Request) {
return
}
var body struct {
Name string `json:"name"`
Name *string `json:"name"`
GroupID *string `json:"groupId"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if err := s.Projects.Rename(id, body.Name); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
if body.Name != nil {
if err := s.Projects.Rename(id, *body.Name); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
if body.GroupID != nil {
if err := s.Projects.SetGroup(id, strings.TrimSpace(*body.GroupID)); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
p, err := s.Projects.Get(id)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
p, _ := s.Projects.Get(id)
writeJSON(w, http.StatusOK, p)
}
+32 -1
View File
@@ -3,6 +3,7 @@ package server
import (
"fmt"
"os"
"strings"
"time"
"task/internal/sqlite"
@@ -78,13 +79,26 @@ func (s *Server) SeedDemo() error {
},
},
}
for i := len(demos) + 1; i <= 50; i++ {
demos = append(demos, struct {
Name string
Tickets []seedTicket
}{
Name: fmt.Sprintf("Sample %02d", i),
Tickets: []seedTicket{
{Title: "Starter ticket", Desc: "Seed-Platzhalter.", Status: ticket.StatusTodo},
},
})
}
now := time.Now().UTC()
created := make([]struct{ ID, Name string }, 0, len(demos))
for _, d := range demos {
p, err := s.Projects.Create("", d.Name)
if err != nil {
return err
}
created = append(created, struct{ ID, Name string }{p.ID, p.Name})
_ = s.DB.SetMember(p.ID, admin.ID, sqlite.RoleOwner)
_ = s.DB.SetMember(p.ID, member.ID, sqlite.RoleMember)
// Explicit local-only sync (no remote backend in the test environment).
@@ -125,6 +139,23 @@ func (s *Server) SeedDemo() error {
}
}
fmt.Fprintf(os.Stderr, "seeded demo: Test + sample projects (users %s + alice/alice), sync=none\n", admin.Username)
kunden, err := s.DB.CreateProjectGroup("Kunden")
if err != nil {
return fmt.Errorf("seed group Kunden: %w", err)
}
intern, err := s.DB.CreateProjectGroup("Intern")
if err != nil {
return fmt.Errorf("seed group Intern: %w", err)
}
for _, c := range created {
switch {
case c.Name == "Website Relaunch" || c.Name == "API Platform" || strings.HasPrefix(c.Name, "Sample 0"):
_ = s.Projects.SetGroup(c.ID, kunden.ID)
case c.Name == "Test" || strings.HasPrefix(c.Name, "Sample 1") || strings.HasPrefix(c.Name, "Sample 2"):
_ = s.Projects.SetGroup(c.ID, intern.ID)
}
}
fmt.Fprintf(os.Stderr, "seeded demo: %d projects, 2 groups (users %s + alice/alice), sync=none\n", len(demos), admin.Username)
return nil
}
+10 -2
View File
@@ -21,8 +21,8 @@ func TestSeedDemo(t *testing.T) {
t.Fatal(err)
}
projects, err := srv.Projects.List()
if err != nil || len(projects) < 3 {
t.Fatalf("projects=%v err=%v", projects, err)
if err != nil || len(projects) != 50 {
t.Fatalf("want 50 projects, got %d err=%v", len(projects), err)
}
var testID string
for _, p := range projects {
@@ -34,6 +34,14 @@ func TestSeedDemo(t *testing.T) {
if testID == "" {
t.Fatal("missing Test project")
}
groups, err := db.ListProjectGroups()
if err != nil || len(groups) != 2 {
t.Fatalf("want 2 groups, got %d err=%v", len(groups), err)
}
p, err := srv.Projects.Get(testID)
if err != nil || p.GroupID == "" {
t.Fatalf("Test project should be grouped, got %+v err=%v", p, err)
}
sync, err := srv.Projects.GetSyncBackend(testID)
if err != nil || sync.Kind != "none" {
t.Fatalf("sync=%+v err=%v", sync, err)
+5
View File
@@ -58,6 +58,11 @@ func (s *Server) routes(mux *http.ServeMux) {
api.HandleFunc("GET /dashboard", s.requireAuth(s.handleDashboard))
api.HandleFunc("GET /project-groups", s.requireAuth(s.handleListGroups))
api.HandleFunc("POST /project-groups", s.requireAuth(s.handleCreateGroup))
api.HandleFunc("PATCH /project-groups/{id}", s.requireAuth(s.handlePatchGroup))
api.HandleFunc("DELETE /project-groups/{id}", s.requireAuth(s.handleDeleteGroup))
api.HandleFunc("GET /projects", s.requireAuth(s.handleListProjects))
api.HandleFunc("POST /projects", s.requireAuth(s.handleCreateProject))
api.HandleFunc("GET /projects/{id}", s.requireAuth(s.handleGetProject))