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
+1 -1
View File
@@ -39,7 +39,7 @@ run-dev: build
TASK_MASTER_HOME=$(DEV_HOME) ./bin/$(BINARY) TASK_MASTER_HOME=$(DEV_HOME) ./bin/$(BINARY)
# Web test environment: SQLite demo against tm_server only (no YouTrack). # Web test environment: SQLite demo against tm_server only (no YouTrack).
# Seeds project "Test" + samples; login admin/admin (alice/alice as member). # Seeds 50 demo projects (Test + samples); login admin/admin (alice/alice as member).
run-server-dev: build-server-go run-server-dev: build-server-go
@rm -f $(DEV_HOME)/.local/share/task-master/task-master.db \ @rm -f $(DEV_HOME)/.local/share/task-master/task-master.db \
$(DEV_HOME)/.local/share/task-master/task-master.db-wal \ $(DEV_HOME)/.local/share/task-master/task-master.db-wal \
+1
View File
@@ -4,4 +4,5 @@ package project
type Project struct { type Project struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
GroupID string `json:"groupId,omitempty"`
} }
+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"})
}
+24 -3
View File
@@ -21,6 +21,7 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
u, _ := s.currentUser(r) u, _ := s.currentUser(r)
var body struct { var body struct {
Name string `json:"name"` Name string `json:"name"`
GroupID string `json:"groupId"`
} }
if err := readJSON(r, &body); err != nil { if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json") writeError(w, http.StatusBadRequest, "invalid json")
@@ -37,6 +38,13 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
return return
} }
_ = s.DB.SetMember(p.ID, u.ID, sqlite.RoleOwner) _ = 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) writeJSON(w, http.StatusCreated, p)
} }
@@ -61,17 +69,30 @@ func (s *Server) handlePatchProject(w http.ResponseWriter, r *http.Request) {
return return
} }
var body struct { var body struct {
Name string `json:"name"` Name *string `json:"name"`
GroupID *string `json:"groupId"`
} }
if err := readJSON(r, &body); err != nil { if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json") writeError(w, http.StatusBadRequest, "invalid json")
return return
} }
if err := s.Projects.Rename(id, body.Name); err != nil { if body.Name != nil {
if err := s.Projects.Rename(id, *body.Name); err != nil {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
} }
p, _ := s.Projects.Get(id) }
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
}
writeJSON(w, http.StatusOK, p) writeJSON(w, http.StatusOK, p)
} }
+32 -1
View File
@@ -3,6 +3,7 @@ package server
import ( import (
"fmt" "fmt"
"os" "os"
"strings"
"time" "time"
"task/internal/sqlite" "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() now := time.Now().UTC()
created := make([]struct{ ID, Name string }, 0, len(demos))
for _, d := range demos { for _, d := range demos {
p, err := s.Projects.Create("", d.Name) p, err := s.Projects.Create("", d.Name)
if err != nil { if err != nil {
return err 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, admin.ID, sqlite.RoleOwner)
_ = s.DB.SetMember(p.ID, member.ID, sqlite.RoleMember) _ = s.DB.SetMember(p.ID, member.ID, sqlite.RoleMember)
// Explicit local-only sync (no remote backend in the test environment). // 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 return nil
} }
+10 -2
View File
@@ -21,8 +21,8 @@ func TestSeedDemo(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
projects, err := srv.Projects.List() projects, err := srv.Projects.List()
if err != nil || len(projects) < 3 { if err != nil || len(projects) != 50 {
t.Fatalf("projects=%v err=%v", projects, err) t.Fatalf("want 50 projects, got %d err=%v", len(projects), err)
} }
var testID string var testID string
for _, p := range projects { for _, p := range projects {
@@ -34,6 +34,14 @@ func TestSeedDemo(t *testing.T) {
if testID == "" { if testID == "" {
t.Fatal("missing Test project") 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) sync, err := srv.Projects.GetSyncBackend(testID)
if err != nil || sync.Kind != "none" { if err != nil || sync.Kind != "none" {
t.Fatalf("sync=%+v err=%v", sync, err) 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 /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("GET /projects", s.requireAuth(s.handleListProjects))
api.HandleFunc("POST /projects", s.requireAuth(s.handleCreateProject)) api.HandleFunc("POST /projects", s.requireAuth(s.handleCreateProject))
api.HandleFunc("GET /projects/{id}", s.requireAuth(s.handleGetProject)) api.HandleFunc("GET /projects/{id}", s.requireAuth(s.handleGetProject))
+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} 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. // List returns all projects sorted by name.
func (s *ProjectStore) List() ([]project.Project, error) { 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 { if err != nil {
return nil, fmt.Errorf("list projects: %w", err) return nil, fmt.Errorf("list projects: %w", err)
} }
defer rows.Close() defer rows.Close()
var out []project.Project var out []project.Project
for rows.Next() { for rows.Next() {
var p project.Project p, err := scanProject(rows)
if err := rows.Scan(&p.ID, &p.Name); err != nil { if err != nil {
return nil, err return nil, err
} }
out = append(out, p) out = append(out, p)
@@ -70,7 +82,7 @@ func (s *ProjectStore) Create(id, name string) (project.Project, error) {
id = uuid.NewString() id = uuid.NewString()
} }
now := formatTime(time.Now().UTC()) 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) id, name, now, now)
if err != nil { if err != nil {
return project.Project{}, fmt.Errorf("create project: %w", err) 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 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. // Rename updates the display name.
func (s *ProjectStore) Rename(id, name string) error { func (s *ProjectStore) Rename(id, name string) error {
name = strings.TrimSpace(name) name = strings.TrimSpace(name)
@@ -114,8 +151,7 @@ func (s *ProjectStore) Delete(id string) error {
// Get returns one project. // Get returns one project.
func (s *ProjectStore) Get(id string) (project.Project, error) { func (s *ProjectStore) Get(id string) (project.Project, error) {
var p project.Project p, err := scanProject(s.db.SQL.QueryRow(`SELECT id, name, group_id FROM projects WHERE id = ?`, id))
err := s.db.SQL.QueryRow(`SELECT id, name FROM projects WHERE id = ?`, id).Scan(&p.ID, &p.Name)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return project.Project{}, fmt.Errorf("project %s: not found", id) 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() return s.List()
} }
rows, err := s.db.SQL.Query(` 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 INNER JOIN project_members m ON m.project_id = p.id
WHERE m.user_id = ? WHERE m.user_id = ?
ORDER BY p.name COLLATE NOCASE`, userID) ORDER BY p.name COLLATE NOCASE`, userID)
@@ -265,8 +301,8 @@ func (s *ProjectStore) ListIDsForUser(userID string, isAdmin bool) ([]project.Pr
defer rows.Close() defer rows.Close()
var out []project.Project var out []project.Project
for rows.Next() { for rows.Next() {
var p project.Project p, err := scanProject(rows)
if err := rows.Scan(&p.ID, &p.Name); err != nil { if err != nil {
return nil, err return nil, err
} }
out = append(out, p) out = append(out, p)
+232 -62
View File
@@ -5,8 +5,11 @@
let user = $state(null) let user = $state(null)
let loading = $state(true) let loading = $state(true)
let error = $state('') let error = $state('')
let view = $state('dashboard') let topTab = $state('dashboard') // dashboard | projects | users | settings
let projectView = $state('') // '' | board | ticket | report | members | settings
let projects = $state([]) let projects = $state([])
let groups = $state([])
let collapsedGroups = $state({})
let projectId = $state('') let projectId = $state('')
let projectName = $state('') let projectName = $state('')
let tickets = $state([]) let tickets = $state([])
@@ -23,10 +26,13 @@
let tokens = $state([]) let tokens = $state([])
let newTokenSecret = $state('') let newTokenSecret = $state('')
let renameName = $state('') let renameName = $state('')
let editGroupId = $state('')
let loginUser = $state('') let loginUser = $state('')
let loginPass = $state('') let loginPass = $state('')
let newProjectName = $state('') let newProjectName = $state('')
let newProjectGroupId = $state('')
let newGroupName = $state('')
let newTicketTitle = $state('') let newTicketTitle = $state('')
let editTitle = $state('') let editTitle = $state('')
let editDesc = $state('') let editDesc = $state('')
@@ -75,10 +81,45 @@
await api.logout() await api.logout()
user = null user = null
projects = [] projects = []
groups = []
} }
async function refreshProjects() { async function refreshProjects() {
projects = await api.listProjects() ;[projects, groups] = await Promise.all([api.listProjects(), api.listGroups()])
}
function projectsInGroup(groupId) {
return projects.filter((p) => (p.groupId || '') === groupId)
}
function ungroupedProjects() {
const ids = new Set(groups.map((g) => g.id))
return projects.filter((p) => !p.groupId || !ids.has(p.groupId))
}
function toggleGroup(id) {
collapsedGroups = { ...collapsedGroups, [id]: !collapsedGroups[id] }
}
async function createGroup() {
error = ''
const name = newGroupName.trim()
if (!name) return
try {
await api.createGroup(name)
newGroupName = ''
await refreshProjects()
} catch (e) {
error = e.message
}
}
async function removeGroup(id) {
if (!confirm('Gruppe löschen? Projekte werden ungeordnet.')) return
await api.deleteGroup(id)
if (newProjectGroupId === id) newProjectGroupId = ''
if (editGroupId === id) editGroupId = ''
await refreshProjects()
} }
async function refreshTimer() { async function refreshTimer() {
@@ -87,16 +128,27 @@
} }
async function openDashboard() { async function openDashboard() {
view = 'dashboard' topTab = 'dashboard'
projectId = '' projectId = ''
projectView = ''
selected = null
dash = await api.dashboard() dash = await api.dashboard()
await refreshProjects() await refreshProjects()
} }
async function openProjects() {
topTab = 'projects'
error = ''
msg = ''
await refreshProjects()
}
async function openProject(id, name = '') { async function openProject(id, name = '') {
topTab = 'projects'
projectId = id projectId = id
projectName = name || projects.find((p) => p.id === id)?.name || id projectName = name || projects.find((p) => p.id === id)?.name || id
view = 'board' projectView = 'board'
selected = null
error = '' error = ''
tickets = await api.listTickets(id) tickets = await api.listTickets(id)
} }
@@ -109,9 +161,10 @@
return return
} }
try { try {
await api.createProject(name) const p = await api.createProject(name, newProjectGroupId)
newProjectName = '' newProjectName = ''
await openDashboard() await refreshProjects()
await openProject(p.id, p.name)
} catch (e) { } catch (e) {
error = e.message error = e.message
} }
@@ -133,7 +186,8 @@
const all = await api.listTime(projectId) const all = await api.listTime(projectId)
ticketTime = all.filter((e) => e.ticketId === t.id) ticketTime = all.filter((e) => e.ticketId === t.id)
await refreshTimer() await refreshTimer()
view = 'ticket' topTab = 'projects'
projectView = 'ticket'
} }
async function saveTicket() { async function saveTicket() {
@@ -151,7 +205,7 @@
if (!confirm('Delete ticket?')) return if (!confirm('Delete ticket?')) return
await api.deleteTicket(projectId, selected.id) await api.deleteTicket(projectId, selected.id)
selected = null selected = null
view = 'board' projectView = 'board'
tickets = await api.listTickets(projectId) tickets = await api.listTickets(projectId)
} }
@@ -190,7 +244,8 @@
} }
async function openReport() { async function openReport() {
view = 'report' topTab = 'projects'
projectView = 'report'
error = '' error = ''
report = await api.report(projectId, reportGroupBy) report = await api.report(projectId, reportGroupBy)
} }
@@ -200,11 +255,27 @@
report = await api.report(projectId, reportGroupBy) report = await api.report(projectId, reportGroupBy)
} }
async function openSettings() { async function openProjectSettings() {
view = 'settings' topTab = 'projects'
projectView = 'settings'
renameName = projectName renameName = projectName
editGroupId = projects.find((p) => p.id === projectId)?.groupId || ''
newTokenSecret = '' newTokenSecret = ''
tokens = await api.listTokens(projectId) tokens = await api.listTokens(projectId)
await refreshProjects()
}
async function saveProjectGroup() {
const p = await api.patchProject(projectId, { groupId: editGroupId })
msg = 'Gruppe gespeichert'
await refreshProjects()
if (p.groupId !== undefined) editGroupId = p.groupId || ''
}
function openGlobalSettings() {
topTab = 'settings'
error = ''
msg = ''
} }
async function saveRename() { async function saveRename() {
@@ -229,7 +300,9 @@
} }
async function openAdmin() { async function openAdmin() {
view = 'admin' topTab = 'users'
error = ''
msg = ''
users = await api.listUsers() users = await api.listUsers()
} }
@@ -258,7 +331,8 @@
} }
async function openMembers() { async function openMembers() {
view = 'members' topTab = 'projects'
projectView = 'members'
members = await api.listMembers(projectId) members = await api.listMembers(projectId)
users = await api.listUsers() users = await api.listUsers()
} }
@@ -280,6 +354,11 @@
msg = text msg = text
} }
} }
function sidebarActive(v) {
if (v === 'board') return projectView === 'board' || projectView === 'ticket'
return projectView === v
}
</script> </script>
{#if loading} {#if loading}
@@ -308,16 +387,12 @@
<header class="topbar"> <header class="topbar">
<div class="brand">task-master</div> <div class="brand">task-master</div>
<nav class="nav"> <nav class="nav">
<button class:active={view === 'dashboard'} onclick={openDashboard}>Dashboard</button> <button class:active={topTab === 'dashboard'} onclick={openDashboard}>Dashboard</button>
{#if projectId} <button class:active={topTab === 'projects'} onclick={openProjects}>Projekte</button>
<button class:active={view === 'board'} onclick={() => openProject(projectId, projectName)}>Board</button>
<button class:active={view === 'report'} onclick={openReport}>Report</button>
<button class:active={view === 'members'} onclick={openMembers}>Members</button>
<button class:active={view === 'settings'} onclick={openSettings}>Settings</button>
{/if}
{#if user.isAdmin} {#if user.isAdmin}
<button class:active={view === 'admin'} onclick={openAdmin}>Users</button> <button class:active={topTab === 'users'} onclick={openAdmin}>User</button>
{/if} {/if}
<button class:active={topTab === 'settings'} onclick={openGlobalSettings}>Settings</button>
</nav> </nav>
<div class="row"> <div class="row">
{#if timer} {#if timer}
@@ -328,10 +403,77 @@
</div> </div>
</header> </header>
<div class="app-body" class:with-sidebar={topTab === 'projects'}>
{#if topTab === 'projects'}
<aside class="sidebar">
<div class="sidebar-section sidebar-projects">
<p class="sidebar-label">Projekte</p>
<div class="sidebar-project-list">
{#each groups as g}
<button type="button" class="sidebar-group" onclick={() => toggleGroup(g.id)}>
<span class="sidebar-twist">{collapsedGroups[g.id] ? '▸' : '▾'}</span>
<span class="sidebar-group-name">{g.name}</span>
<span class="muted">{projectsInGroup(g.id).length}</span>
</button>
{#if !collapsedGroups[g.id]}
{#each projectsInGroup(g.id) as p}
<button
class="sidebar-item sidebar-child"
class:active={projectId === p.id}
onclick={() => openProject(p.id, p.name)}
>{p.name}</button>
{:else}
<p class="muted sidebar-child">leer</p>
{/each}
{/if}
{/each}
{#if ungroupedProjects().length}
{#if groups.length}
<p class="sidebar-label sidebar-ungrouped-label">Ohne Gruppe</p>
{/if}
{#each ungroupedProjects() as p}
<button
class="sidebar-item"
class:active={projectId === p.id}
onclick={() => openProject(p.id, p.name)}
>{p.name}</button>
{/each}
{:else if !groups.length && !projects.length}
<p class="muted">Noch keine Projekte.</p>
{/if}
</div>
<div class="sidebar-create stack">
<input placeholder="Neues Projekt" bind:value={newProjectName} />
{#if groups.length}
<select bind:value={newProjectGroupId}>
<option value="">Ohne Gruppe</option>
{#each groups as g}<option value={g.id}>{g.name}</option>{/each}
</select>
{/if}
<button class="primary" onclick={createProject}>Anlegen</button>
<div class="row" style="margin-top: 0.35rem;">
<input placeholder="Neue Gruppe" bind:value={newGroupName} />
<button onclick={createGroup}>+</button>
</div>
</div>
</div>
{#if projectId}
<div class="sidebar-section sidebar-subnav">
<p class="sidebar-label">{projectName}</p>
<button class="sidebar-item" class:active={sidebarActive('board')} onclick={() => openProject(projectId, projectName)}>Board</button>
<button class="sidebar-item" class:active={sidebarActive('report')} onclick={openReport}>Report</button>
<button class="sidebar-item" class:active={sidebarActive('members')} onclick={openMembers}>Members</button>
<button class="sidebar-item" class:active={sidebarActive('settings')} onclick={openProjectSettings}>Settings</button>
</div>
{/if}
</aside>
{/if}
<main class="content">
{#if error}<p class="error">{error}</p>{/if} {#if error}<p class="error">{error}</p>{/if}
{#if msg}<p class="muted">{msg}</p>{/if} {#if msg}<p class="muted">{msg}</p>{/if}
{#if view === 'dashboard'} {#if topTab === 'dashboard'}
<div class="grid" style="grid-template-columns: 1.2fr 1fr; gap: 1rem;"> <div class="grid" style="grid-template-columns: 1.2fr 1fr; gap: 1rem;">
<section class="panel stack"> <section class="panel stack">
<h2>Projects</h2> <h2>Projects</h2>
@@ -343,11 +485,7 @@
{:else} {:else}
<p class="muted">No projects yet.</p> <p class="muted">No projects yet.</p>
{/each} {/each}
<h3>New project</h3> <button onclick={openProjects}>Alle Projekte →</button>
<div class="row">
<input placeholder="Project name" bind:value={newProjectName} />
<button class="primary" onclick={createProject}>Create</button>
</div>
</section> </section>
<div class="stack"> <div class="stack">
<section class="panel stack"> <section class="panel stack">
@@ -375,7 +513,12 @@
</section> </section>
</div> </div>
</div> </div>
{:else if view === 'board'} {:else if topTab === 'projects' && !projectId}
<section class="panel stack">
<h2>Projekte</h2>
<p class="muted">Wähle links ein Projekt oder lege ein neues an.</p>
</section>
{:else if topTab === 'projects' && projectView === 'board'}
<section class="panel stack"> <section class="panel stack">
<div class="row" style="justify-content: space-between;"> <div class="row" style="justify-content: space-between;">
<h2>{projectName}</h2> <h2>{projectName}</h2>
@@ -399,10 +542,10 @@
{/each} {/each}
</div> </div>
</section> </section>
{:else if view === 'ticket' && selected} {:else if topTab === 'projects' && projectView === 'ticket' && selected}
<section class="panel stack"> <section class="panel stack">
<div class="row"> <div class="row">
<button onclick={() => (view = 'board')}>← Board</button> <button onclick={() => (projectView = 'board')}>← Board</button>
<button class="danger" onclick={removeTicket}>Delete</button> <button class="danger" onclick={removeTicket}>Delete</button>
</div> </div>
<label>Title<input bind:value={editTitle} /></label> <label>Title<input bind:value={editTitle} /></label>
@@ -456,7 +599,7 @@
</tbody> </tbody>
</table> </table>
</section> </section>
{:else if view === 'report'} {:else if topTab === 'projects' && projectView === 'report'}
<section class="panel stack"> <section class="panel stack">
<div class="row" style="justify-content: space-between;"> <div class="row" style="justify-content: space-between;">
<h2>Report — {projectName}</h2> <h2>Report — {projectName}</h2>
@@ -480,7 +623,7 @@
</table> </table>
{/if} {/if}
</section> </section>
{:else if view === 'settings'} {:else if topTab === 'projects' && projectView === 'settings'}
<section class="panel stack"> <section class="panel stack">
<h2>Settings — {projectName}</h2> <h2>Settings — {projectName}</h2>
<label>Project ID <label>Project ID
@@ -495,6 +638,26 @@
<button class="primary" onclick={saveRename}>Rename</button> <button class="primary" onclick={saveRename}>Rename</button>
</div> </div>
</label> </label>
<label>Gruppe
<div class="row">
<select bind:value={editGroupId} style="width: auto; flex: 1;">
<option value="">Ohne Gruppe</option>
{#each groups as g}<option value={g.id}>{g.name}</option>{/each}
</select>
<button class="primary" onclick={saveProjectGroup}>Speichern</button>
</div>
</label>
{#if groups.length}
<div class="stack">
<h3>Gruppen verwalten</h3>
{#each groups as g}
<div class="row">
<span style="flex:1">{g.name}</span>
<button class="danger" onclick={() => removeGroup(g.id)}>Löschen</button>
</div>
{/each}
</div>
{/if}
<h3>API tokens (for TUI)</h3> <h3>API tokens (for TUI)</h3>
<p class="muted">Create a write token, then in `tm`: project edit → backend taskmaster → URL, token, this project id.</p> <p class="muted">Create a write token, then in `tm`: project edit → backend taskmaster → URL, token, this project id.</p>
@@ -531,36 +694,7 @@
</tbody> </tbody>
</table> </table>
</section> </section>
{:else if view === 'admin'} {:else if topTab === 'projects' && projectView === 'members'}
<section class="panel stack">
<h2>Users</h2>
<table>
<thead><tr><th>Username</th><th>Admin</th><th></th></tr></thead>
<tbody>
{#each users as u}
<tr>
<td>{u.username}</td>
<td>
<button onclick={() => toggleAdmin(u)} disabled={u.id === user.id}>
{u.isAdmin ? 'yes' : 'no'}
</button>
</td>
<td>
{#if u.id !== user.id}
<button class="danger" onclick={() => deleteUser(u)}>Delete</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
<h3>Create user</h3>
<input placeholder="username" bind:value={newUserName} />
<input type="password" placeholder="password" bind:value={newUserPass} />
<label class="row"><input type="checkbox" bind:checked={newUserAdmin} /> Admin</label>
<button class="primary" onclick={createUser}>Create</button>
</section>
{:else if view === 'members'}
<section class="panel stack"> <section class="panel stack">
<h2>Members — {projectName}</h2> <h2>Members — {projectName}</h2>
<table> <table>
@@ -589,6 +723,42 @@
<button class="primary" onclick={addMember}>Add</button> <button class="primary" onclick={addMember}>Add</button>
</div> </div>
</section> </section>
{:else if topTab === 'users'}
<section class="panel stack">
<h2>Users</h2>
<table>
<thead><tr><th>Username</th><th>Admin</th><th></th></tr></thead>
<tbody>
{#each users as u}
<tr>
<td>{u.username}</td>
<td>
<button onclick={() => toggleAdmin(u)} disabled={u.id === user.id}>
{u.isAdmin ? 'yes' : 'no'}
</button>
</td>
<td>
{#if u.id !== user.id}
<button class="danger" onclick={() => deleteUser(u)}>Delete</button>
{/if} {/if}
</td>
</tr>
{/each}
</tbody>
</table>
<h3>Create user</h3>
<input placeholder="username" bind:value={newUserName} />
<input type="password" placeholder="password" bind:value={newUserPass} />
<label class="row"><input type="checkbox" bind:checked={newUserAdmin} /> Admin</label>
<button class="primary" onclick={createUser}>Create</button>
</section>
{:else if topTab === 'settings'}
<section class="panel stack">
<h2>Settings</h2>
<p class="muted">Globale Einstellungen folgen hier später.</p>
</section>
{/if}
</main>
</div>
</div> </div>
{/if} {/if}
+135 -4
View File
@@ -69,18 +69,23 @@ code {
} }
.app-shell { .app-shell {
max-width: 1100px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 1.25rem; padding: 0 1.25rem 1.25rem;
} }
.topbar { .topbar {
position: sticky;
top: 0;
z-index: 100;
display: flex; display: flex;
gap: 1rem; gap: 1rem;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-bottom: 1.25rem; margin: 0 -1.25rem 1.25rem;
padding-bottom: 0.85rem; padding: 0.85rem 1.25rem;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--bg) 92%, transparent);
backdrop-filter: blur(8px);
} }
.brand { .brand {
font-weight: 700; font-weight: 700;
@@ -93,6 +98,132 @@ code {
color: var(--accent-ink); color: var(--accent-ink);
border-color: var(--accent); border-color: var(--accent);
} }
.app-body {
display: grid;
gap: 1rem;
min-width: 0;
}
.app-body.with-sidebar {
grid-template-columns: 220px minmax(0, 1fr);
}
.content { min-width: 0; }
.sidebar {
display: flex;
flex-direction: column;
gap: 1rem;
align-self: start;
max-height: calc(100vh - 5.5rem);
border-right: 1px solid var(--line);
padding-right: 0.85rem;
min-height: 0;
}
.sidebar-section {
display: grid;
gap: 0.35rem;
align-content: start;
}
.sidebar-projects {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-height: 0;
flex: 1 1 auto;
}
.sidebar-project-list {
--sidebar-row: 2.05rem;
display: grid;
gap: 0.2rem;
align-content: start;
overflow-y: auto;
min-height: 0;
flex: 0 1 auto;
max-height: calc(12 * var(--sidebar-row));
padding-right: 0.15rem;
}
.sidebar-group {
display: flex;
align-items: center;
gap: 0.35rem;
width: 100%;
text-align: left;
background: transparent;
border-color: transparent;
padding: 0.3rem 0.5rem;
color: var(--muted);
font-weight: 600;
font-size: 0.86rem;
}
.sidebar-group:hover {
background: var(--panel-2);
border-color: var(--line);
color: var(--ink);
}
.sidebar-twist {
display: inline-block;
width: 0.9rem;
flex: 0 0 auto;
}
.sidebar-group-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: inherit;
}
.sidebar-child {
padding-left: 1.35rem;
}
.sidebar-ungrouped-label {
margin-top: 0.45rem;
}
.sidebar-subnav {
flex: 0 0 auto;
padding-top: 0.85rem;
border-top: 1px solid var(--line);
}
.sidebar-label {
margin: 0.25rem 0 0.35rem;
font-size: 0.78rem;
font-weight: 650;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
flex: 0 0 auto;
}
.sidebar-item {
width: 100%;
text-align: left;
background: transparent;
border-color: transparent;
}
.sidebar-item:hover {
background: var(--panel-2);
border-color: var(--line);
}
.sidebar-item.active {
background: var(--accent);
color: var(--accent-ink);
border-color: var(--accent);
font-weight: 600;
}
.sidebar-create {
flex: 0 0 auto;
margin-top: 0.25rem;
padding-top: 0.65rem;
border-top: 1px solid var(--line);
}
@media (max-width: 800px) {
.app-body.with-sidebar {
grid-template-columns: 1fr;
}
.sidebar {
border-right: none;
border-bottom: 1px solid var(--line);
padding-right: 0;
padding-bottom: 0.85rem;
}
}
.panel { .panel {
background: linear-gradient(180deg, var(--panel) 0%, #14191f 100%); background: linear-gradient(180deg, var(--panel) 0%, #14191f 100%);
border: 1px solid var(--line); border: 1px solid var(--line);
+12 -2
View File
@@ -29,11 +29,21 @@ export const api = {
dashboard: () => request('/dashboard'), dashboard: () => request('/dashboard'),
listProjects: () => request('/projects'), listProjects: () => request('/projects'),
createProject: (name) => createProject: (name, groupId = '') =>
request('/projects', { method: 'POST', body: JSON.stringify({ name }) }), request('/projects', {
method: 'POST',
body: JSON.stringify(groupId ? { name, groupId } : { name }),
}),
patchProject: (id, body) => patchProject: (id, body) =>
request(`/projects/${id}`, { method: 'PATCH', body: JSON.stringify(body) }), request(`/projects/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
deleteProject: (id) => request(`/projects/${id}`, { method: 'DELETE' }), deleteProject: (id) => request(`/projects/${id}`, { method: 'DELETE' }),
listGroups: () => request('/project-groups'),
createGroup: (name) =>
request('/project-groups', { method: 'POST', body: JSON.stringify({ name }) }),
patchGroup: (id, body) =>
request(`/project-groups/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
deleteGroup: (id) => request(`/project-groups/${id}`, { method: 'DELETE' }),
listMembers: (id) => request(`/projects/${id}/members`), listMembers: (id) => request(`/projects/${id}/members`),
setMember: (id, userId, role) => setMember: (id, userId, role) =>
request(`/projects/${id}/members/${userId}`, { method: 'PUT', body: JSON.stringify({ role }) }), request(`/projects/${id}/members/${userId}`, { method: 'PUT', body: JSON.stringify({ role }) }),