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:
@@ -2,6 +2,7 @@ package project
|
||||
|
||||
// Project is a lightweight workspace identified by a directory name.
|
||||
type Project struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
GroupID string `json:"groupId,omitempty"`
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ProjectGroup is a folder for organizing projects in the UI tree.
|
||||
type ProjectGroup struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
}
|
||||
|
||||
// ListProjectGroups returns all groups sorted by sort_order then name.
|
||||
func (db *DB) ListProjectGroups() ([]ProjectGroup, error) {
|
||||
rows, err := db.SQL.Query(`
|
||||
SELECT id, name, sort_order FROM project_groups
|
||||
ORDER BY sort_order ASC, name COLLATE NOCASE`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list project groups: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ProjectGroup
|
||||
for rows.Next() {
|
||||
var g ProjectGroup
|
||||
if err := rows.Scan(&g.ID, &g.Name, &g.SortOrder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
if out == nil {
|
||||
out = []ProjectGroup{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CreateProjectGroup inserts a new group.
|
||||
func (db *DB) CreateProjectGroup(name string) (ProjectGroup, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return ProjectGroup{}, fmt.Errorf("name is required")
|
||||
}
|
||||
id := uuid.NewString()
|
||||
var maxOrd sql.NullInt64
|
||||
_ = db.SQL.QueryRow(`SELECT MAX(sort_order) FROM project_groups`).Scan(&maxOrd)
|
||||
ord := 0
|
||||
if maxOrd.Valid {
|
||||
ord = int(maxOrd.Int64) + 1
|
||||
}
|
||||
now := formatTime(time.Now().UTC())
|
||||
_, err := db.SQL.Exec(`INSERT INTO project_groups(id, name, sort_order, created_at) VALUES(?, ?, ?, ?)`,
|
||||
id, name, ord, now)
|
||||
if err != nil {
|
||||
return ProjectGroup{}, fmt.Errorf("create project group: %w", err)
|
||||
}
|
||||
return ProjectGroup{ID: id, Name: name, SortOrder: ord}, nil
|
||||
}
|
||||
|
||||
// RenameProjectGroup updates the group name.
|
||||
func (db *DB) RenameProjectGroup(id, name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if id == "" || name == "" {
|
||||
return fmt.Errorf("id and name are required")
|
||||
}
|
||||
res, err := db.SQL.Exec(`UPDATE project_groups SET name = ? WHERE id = ?`, name, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("group %s: not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteProjectGroup removes a group; projects in it become ungrouped.
|
||||
func (db *DB) DeleteProjectGroup(id string) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("id is required")
|
||||
}
|
||||
tx, err := db.SQL.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if _, err := tx.Exec(`UPDATE projects SET group_id = NULL WHERE group_id = ?`, id); err != nil {
|
||||
return fmt.Errorf("ungroup projects: %w", err)
|
||||
}
|
||||
res, err := tx.Exec(`DELETE FROM project_groups WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete group %s: %w", id, err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("group %s: not found", id)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// GetProjectGroup returns one group.
|
||||
func (db *DB) GetProjectGroup(id string) (ProjectGroup, error) {
|
||||
var g ProjectGroup
|
||||
err := db.SQL.QueryRow(`SELECT id, name, sort_order FROM project_groups WHERE id = ?`, id).
|
||||
Scan(&g.ID, &g.Name, &g.SortOrder)
|
||||
if err == sql.ErrNoRows {
|
||||
return ProjectGroup{}, fmt.Errorf("group %s: not found", id)
|
||||
}
|
||||
if err != nil {
|
||||
return ProjectGroup{}, err
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE IF NOT EXISTS project_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_project_groups_sort ON project_groups(sort_order, name COLLATE NOCASE);
|
||||
|
||||
ALTER TABLE projects ADD COLUMN group_id TEXT REFERENCES project_groups(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_group ON projects(group_id);
|
||||
@@ -22,17 +22,29 @@ func NewProjectStore(db *DB) *ProjectStore {
|
||||
return &ProjectStore{db: db}
|
||||
}
|
||||
|
||||
func scanProject(scanner interface{ Scan(dest ...any) error }) (project.Project, error) {
|
||||
var p project.Project
|
||||
var groupID sql.NullString
|
||||
if err := scanner.Scan(&p.ID, &p.Name, &groupID); err != nil {
|
||||
return project.Project{}, err
|
||||
}
|
||||
if groupID.Valid {
|
||||
p.GroupID = groupID.String
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// List returns all projects sorted by name.
|
||||
func (s *ProjectStore) List() ([]project.Project, error) {
|
||||
rows, err := s.db.SQL.Query(`SELECT id, name FROM projects ORDER BY name COLLATE NOCASE`)
|
||||
rows, err := s.db.SQL.Query(`SELECT id, name, group_id FROM projects ORDER BY name COLLATE NOCASE`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list projects: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []project.Project
|
||||
for rows.Next() {
|
||||
var p project.Project
|
||||
if err := rows.Scan(&p.ID, &p.Name); err != nil {
|
||||
p, err := scanProject(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
@@ -70,7 +82,7 @@ func (s *ProjectStore) Create(id, name string) (project.Project, error) {
|
||||
id = uuid.NewString()
|
||||
}
|
||||
now := formatTime(time.Now().UTC())
|
||||
_, err := s.db.SQL.Exec(`INSERT INTO projects(id, name, created_at, updated_at) VALUES(?, ?, ?, ?)`,
|
||||
_, err := s.db.SQL.Exec(`INSERT INTO projects(id, name, created_at, updated_at, group_id) VALUES(?, ?, ?, ?, NULL)`,
|
||||
id, name, now, now)
|
||||
if err != nil {
|
||||
return project.Project{}, fmt.Errorf("create project: %w", err)
|
||||
@@ -78,6 +90,31 @@ func (s *ProjectStore) Create(id, name string) (project.Project, error) {
|
||||
return project.Project{ID: id, Name: name}, nil
|
||||
}
|
||||
|
||||
// SetGroup assigns a project to a group. Empty groupID clears the assignment.
|
||||
func (s *ProjectStore) SetGroup(id, groupID string) error {
|
||||
id = strings.TrimSpace(id)
|
||||
groupID = strings.TrimSpace(groupID)
|
||||
if id == "" {
|
||||
return fmt.Errorf("project id is required")
|
||||
}
|
||||
var arg any
|
||||
if groupID == "" {
|
||||
arg = nil
|
||||
} else {
|
||||
arg = groupID
|
||||
}
|
||||
res, err := s.db.SQL.Exec(`UPDATE projects SET group_id = ?, updated_at = ? WHERE id = ?`,
|
||||
arg, formatTime(time.Now().UTC()), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set project group: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("project %s: not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rename updates the display name.
|
||||
func (s *ProjectStore) Rename(id, name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
@@ -114,8 +151,7 @@ func (s *ProjectStore) Delete(id string) error {
|
||||
|
||||
// Get returns one project.
|
||||
func (s *ProjectStore) Get(id string) (project.Project, error) {
|
||||
var p project.Project
|
||||
err := s.db.SQL.QueryRow(`SELECT id, name FROM projects WHERE id = ?`, id).Scan(&p.ID, &p.Name)
|
||||
p, err := scanProject(s.db.SQL.QueryRow(`SELECT id, name, group_id FROM projects WHERE id = ?`, id))
|
||||
if err == sql.ErrNoRows {
|
||||
return project.Project{}, fmt.Errorf("project %s: not found", id)
|
||||
}
|
||||
@@ -255,7 +291,7 @@ func (s *ProjectStore) ListIDsForUser(userID string, isAdmin bool) ([]project.Pr
|
||||
return s.List()
|
||||
}
|
||||
rows, err := s.db.SQL.Query(`
|
||||
SELECT p.id, p.name FROM projects p
|
||||
SELECT p.id, p.name, p.group_id FROM projects p
|
||||
INNER JOIN project_members m ON m.project_id = p.id
|
||||
WHERE m.user_id = ?
|
||||
ORDER BY p.name COLLATE NOCASE`, userID)
|
||||
@@ -265,8 +301,8 @@ func (s *ProjectStore) ListIDsForUser(userID string, isAdmin bool) ([]project.Pr
|
||||
defer rows.Close()
|
||||
var out []project.Project
|
||||
for rows.Next() {
|
||||
var p project.Project
|
||||
if err := rows.Scan(&p.ID, &p.Name); err != nil {
|
||||
p, err := scanProject(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
|
||||
Reference in New Issue
Block a user