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
+117
View File
@@ -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);
+45 -9
View File
@@ -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)