feat: web app support for boards and columns
This commit is contained in:
@@ -219,6 +219,7 @@ func main() {
|
||||
|
||||
// Start the WebSocket hub
|
||||
go gateway.Hub.Run(ctx)
|
||||
gateways.RegisterTaskNotifications(ctx, eventBus, gateway.Hub, log)
|
||||
|
||||
// Set up historical logs provider for task subscriptions
|
||||
gateway.Hub.SetHistoricalLogsProvider(func(ctx context.Context, taskID string) ([]*ws.Message, error) {
|
||||
|
||||
@@ -9,6 +9,27 @@ const (
|
||||
TaskDeleted = "task.deleted"
|
||||
)
|
||||
|
||||
// Event types for workspaces
|
||||
const (
|
||||
WorkspaceCreated = "workspace.created"
|
||||
WorkspaceUpdated = "workspace.updated"
|
||||
WorkspaceDeleted = "workspace.deleted"
|
||||
)
|
||||
|
||||
// Event types for boards
|
||||
const (
|
||||
BoardCreated = "board.created"
|
||||
BoardUpdated = "board.updated"
|
||||
BoardDeleted = "board.deleted"
|
||||
)
|
||||
|
||||
// Event types for columns
|
||||
const (
|
||||
ColumnCreated = "column.created"
|
||||
ColumnUpdated = "column.updated"
|
||||
ColumnDeleted = "column.deleted"
|
||||
)
|
||||
|
||||
// Event types for agents
|
||||
const (
|
||||
AgentStarted = "agent.started"
|
||||
@@ -33,4 +54,3 @@ func BuildACPSubject(taskID string) string {
|
||||
func BuildACPWildcardSubject() string {
|
||||
return ACPMessage + ".*"
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type TaskEventBroadcaster struct {
|
||||
hub *Hub
|
||||
subscriptions []bus.Subscription
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
func RegisterTaskNotifications(ctx context.Context, eventBus bus.EventBus, hub *Hub, log *logger.Logger) *TaskEventBroadcaster {
|
||||
b := &TaskEventBroadcaster{
|
||||
hub: hub,
|
||||
logger: log.WithFields(zap.String("component", "ws-task-broadcaster")),
|
||||
}
|
||||
if eventBus == nil {
|
||||
return b
|
||||
}
|
||||
|
||||
b.subscribe(eventBus, events.WorkspaceCreated, ws.ActionWorkspaceCreated)
|
||||
b.subscribe(eventBus, events.WorkspaceUpdated, ws.ActionWorkspaceUpdated)
|
||||
b.subscribe(eventBus, events.WorkspaceDeleted, ws.ActionWorkspaceDeleted)
|
||||
b.subscribe(eventBus, events.BoardCreated, ws.ActionBoardCreated)
|
||||
b.subscribe(eventBus, events.BoardUpdated, ws.ActionBoardUpdated)
|
||||
b.subscribe(eventBus, events.BoardDeleted, ws.ActionBoardDeleted)
|
||||
b.subscribe(eventBus, events.ColumnCreated, ws.ActionColumnCreated)
|
||||
b.subscribe(eventBus, events.ColumnUpdated, ws.ActionColumnUpdated)
|
||||
b.subscribe(eventBus, events.ColumnDeleted, ws.ActionColumnDeleted)
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
b.Close()
|
||||
}()
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *TaskEventBroadcaster) Close() {
|
||||
for _, sub := range b.subscriptions {
|
||||
if sub != nil && sub.IsValid() {
|
||||
_ = sub.Unsubscribe()
|
||||
}
|
||||
}
|
||||
b.subscriptions = nil
|
||||
}
|
||||
|
||||
func (b *TaskEventBroadcaster) subscribe(eventBus bus.EventBus, subject, action string) {
|
||||
sub, err := eventBus.Subscribe(subject, func(ctx context.Context, event *bus.Event) error {
|
||||
msg, err := ws.NewNotification(action, event.Data)
|
||||
if err != nil {
|
||||
b.logger.Error("failed to build websocket notification", zap.String("action", action), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
b.hub.Broadcast(msg)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
b.logger.Error("failed to subscribe to events", zap.String("subject", subject), zap.Error(err))
|
||||
return
|
||||
}
|
||||
b.subscriptions = append(b.subscriptions, sub)
|
||||
}
|
||||
@@ -97,6 +97,7 @@ func (c *BoardController) CreateColumn(ctx context.Context, req dto.CreateColumn
|
||||
Name: req.Name,
|
||||
Position: req.Position,
|
||||
State: req.State,
|
||||
Color: req.Color,
|
||||
})
|
||||
if err != nil {
|
||||
return dto.ColumnDTO{}, err
|
||||
@@ -104,6 +105,23 @@ func (c *BoardController) CreateColumn(ctx context.Context, req dto.CreateColumn
|
||||
return dto.FromColumn(column), nil
|
||||
}
|
||||
|
||||
func (c *BoardController) UpdateColumn(ctx context.Context, req dto.UpdateColumnRequest) (dto.ColumnDTO, error) {
|
||||
column, err := c.service.UpdateColumn(ctx, req.ID, &service.UpdateColumnRequest{
|
||||
Name: req.Name,
|
||||
Position: req.Position,
|
||||
State: req.State,
|
||||
Color: req.Color,
|
||||
})
|
||||
if err != nil {
|
||||
return dto.ColumnDTO{}, err
|
||||
}
|
||||
return dto.FromColumn(column), nil
|
||||
}
|
||||
|
||||
func (c *BoardController) DeleteColumn(ctx context.Context, req dto.GetColumnRequest) error {
|
||||
return c.service.DeleteColumn(ctx, req.ID)
|
||||
}
|
||||
|
||||
func (c *BoardController) GetSnapshot(ctx context.Context, req dto.GetBoardSnapshotRequest) (dto.BoardSnapshotDTO, error) {
|
||||
board, err := c.service.GetBoard(ctx, req.BoardID)
|
||||
if err != nil {
|
||||
|
||||
@@ -31,6 +31,7 @@ type ColumnDTO struct {
|
||||
Name string `json:"name"`
|
||||
Position int `json:"position"`
|
||||
State v1.TaskState `json:"state"`
|
||||
Color string `json:"color"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -123,6 +124,7 @@ func FromColumn(column *models.Column) ColumnDTO {
|
||||
Name: column.Name,
|
||||
Position: column.Position,
|
||||
State: column.State,
|
||||
Color: column.Color,
|
||||
CreatedAt: column.CreatedAt,
|
||||
UpdatedAt: column.UpdatedAt,
|
||||
}
|
||||
|
||||
@@ -61,6 +61,15 @@ type CreateColumnRequest struct {
|
||||
Name string
|
||||
Position int
|
||||
State v1.TaskState
|
||||
Color string
|
||||
}
|
||||
|
||||
type UpdateColumnRequest struct {
|
||||
ID string
|
||||
Name *string
|
||||
Position *int
|
||||
State *v1.TaskState
|
||||
Color *string
|
||||
}
|
||||
|
||||
type GetBoardSnapshotRequest struct {
|
||||
|
||||
@@ -39,6 +39,12 @@ func (h *BoardHandlers) registerHTTP(router *gin.Engine) {
|
||||
api.GET("/boards/:id", h.httpGetBoard)
|
||||
api.GET("/boards/:id/columns", h.httpListColumns)
|
||||
api.GET("/boards/:id/snapshot", h.httpGetBoardSnapshot)
|
||||
api.POST("/boards", h.httpCreateBoard)
|
||||
api.PATCH("/boards/:id", h.httpUpdateBoard)
|
||||
api.DELETE("/boards/:id", h.httpDeleteBoard)
|
||||
api.POST("/boards/:id/columns", h.httpCreateColumn)
|
||||
api.PUT("/columns/:id", h.httpUpdateColumn)
|
||||
api.DELETE("/columns/:id", h.httpDeleteColumn)
|
||||
}
|
||||
|
||||
func (h *BoardHandlers) registerWS(dispatcher *ws.Dispatcher) {
|
||||
@@ -50,6 +56,8 @@ func (h *BoardHandlers) registerWS(dispatcher *ws.Dispatcher) {
|
||||
dispatcher.RegisterFunc(ws.ActionColumnList, h.wsListColumns)
|
||||
dispatcher.RegisterFunc(ws.ActionColumnCreate, h.wsCreateColumn)
|
||||
dispatcher.RegisterFunc(ws.ActionColumnGet, h.wsGetColumn)
|
||||
dispatcher.RegisterFunc(ws.ActionColumnUpdate, h.wsUpdateColumn)
|
||||
dispatcher.RegisterFunc(ws.ActionColumnDelete, h.wsDeleteColumn)
|
||||
}
|
||||
|
||||
// HTTP handlers
|
||||
@@ -87,6 +95,67 @@ func (h *BoardHandlers) httpGetBoard(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type httpCreateBoardRequest struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
func (h *BoardHandlers) httpCreateBoard(c *gin.Context) {
|
||||
var body httpCreateBoardRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
if body.Name == "" || body.WorkspaceID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id and name are required"})
|
||||
return
|
||||
}
|
||||
resp, err := h.controller.CreateBoard(c.Request.Context(), dto.CreateBoardRequest{
|
||||
WorkspaceID: body.WorkspaceID,
|
||||
Name: body.Name,
|
||||
Description: body.Description,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to create board", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create board"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
type httpUpdateBoardRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
|
||||
func (h *BoardHandlers) httpUpdateBoard(c *gin.Context) {
|
||||
var body httpUpdateBoardRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
resp, err := h.controller.UpdateBoard(c.Request.Context(), dto.UpdateBoardRequest{
|
||||
ID: c.Param("id"),
|
||||
Name: body.Name,
|
||||
Description: body.Description,
|
||||
})
|
||||
if err != nil {
|
||||
handleNotFound(c, h.logger, err, "board not found")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *BoardHandlers) httpDeleteBoard(c *gin.Context) {
|
||||
resp, err := h.controller.DeleteBoard(c.Request.Context(), dto.DeleteBoardRequest{ID: c.Param("id")})
|
||||
if err != nil {
|
||||
handleNotFound(c, h.logger, err, "board not found")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *BoardHandlers) httpListColumns(c *gin.Context) {
|
||||
resp, err := h.controller.ListColumns(c.Request.Context(), dto.ListColumnsRequest{BoardID: c.Param("id")})
|
||||
if err != nil {
|
||||
@@ -96,6 +165,80 @@ func (h *BoardHandlers) httpListColumns(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type httpCreateColumnRequest struct {
|
||||
Name string `json:"name"`
|
||||
Position int `json:"position"`
|
||||
State v1.TaskState `json:"state"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
func (h *BoardHandlers) httpCreateColumn(c *gin.Context) {
|
||||
var body httpCreateColumnRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
h.logger.Error("failed to bind create column request", zap.Error(err), zap.String("board_id", c.Param("id")))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
if body.Name == "" {
|
||||
h.logger.Error("create column missing name", zap.String("board_id", c.Param("id")))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
state := body.State
|
||||
if state == "" {
|
||||
state = v1.TaskStateTODO
|
||||
}
|
||||
resp, err := h.controller.CreateColumn(c.Request.Context(), dto.CreateColumnRequest{
|
||||
BoardID: c.Param("id"),
|
||||
Name: body.Name,
|
||||
Position: body.Position,
|
||||
State: state,
|
||||
Color: body.Color,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to create column", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create column"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
type httpUpdateColumnRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Position *int `json:"position"`
|
||||
State *v1.TaskState `json:"state"`
|
||||
Color *string `json:"color"`
|
||||
}
|
||||
|
||||
func (h *BoardHandlers) httpUpdateColumn(c *gin.Context) {
|
||||
var body httpUpdateColumnRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
resp, err := h.controller.UpdateColumn(c.Request.Context(), dto.UpdateColumnRequest{
|
||||
ID: c.Param("id"),
|
||||
Name: body.Name,
|
||||
Position: body.Position,
|
||||
State: body.State,
|
||||
Color: body.Color,
|
||||
})
|
||||
if err != nil {
|
||||
handleNotFound(c, h.logger, err, "column not found")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *BoardHandlers) httpDeleteColumn(c *gin.Context) {
|
||||
if err := h.controller.DeleteColumn(c.Request.Context(), dto.GetColumnRequest{ID: c.Param("id")}); err != nil {
|
||||
h.logger.Error("failed to delete column", zap.Error(err), zap.String("column_id", c.Param("id")))
|
||||
handleNotFound(c, h.logger, err, "column not found")
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *BoardHandlers) httpGetBoardSnapshot(c *gin.Context) {
|
||||
resp, err := h.controller.GetSnapshot(c.Request.Context(), dto.GetBoardSnapshotRequest{BoardID: c.Param("id")})
|
||||
if err != nil {
|
||||
@@ -248,6 +391,7 @@ type wsCreateColumnRequest struct {
|
||||
Name string `json:"name"`
|
||||
Position int `json:"position"`
|
||||
State string `json:"state,omitempty"`
|
||||
Color string `json:"color,omitempty"`
|
||||
}
|
||||
|
||||
func (h *BoardHandlers) wsCreateColumn(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
@@ -272,6 +416,7 @@ func (h *BoardHandlers) wsCreateColumn(ctx context.Context, msg *ws.Message) (*w
|
||||
Name: req.Name,
|
||||
Position: req.Position,
|
||||
State: state,
|
||||
Color: req.Color,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to create column", zap.Error(err))
|
||||
@@ -299,3 +444,52 @@ func (h *BoardHandlers) wsGetColumn(ctx context.Context, msg *ws.Message) (*ws.M
|
||||
}
|
||||
return ws.NewResponse(msg.ID, msg.Action, resp)
|
||||
}
|
||||
|
||||
type wsUpdateColumnRequest struct {
|
||||
ID string `json:"id"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Position *int `json:"position,omitempty"`
|
||||
State *v1.TaskState `json:"state,omitempty"`
|
||||
Color *string `json:"color,omitempty"`
|
||||
}
|
||||
|
||||
func (h *BoardHandlers) wsUpdateColumn(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
var req wsUpdateColumnRequest
|
||||
if err := msg.ParsePayload(&req); err != nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
|
||||
}
|
||||
if req.ID == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
|
||||
}
|
||||
|
||||
resp, err := h.controller.UpdateColumn(ctx, dto.UpdateColumnRequest{
|
||||
ID: req.ID,
|
||||
Name: req.Name,
|
||||
Position: req.Position,
|
||||
State: req.State,
|
||||
Color: req.Color,
|
||||
})
|
||||
if err != nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Column not found", nil)
|
||||
}
|
||||
return ws.NewResponse(msg.ID, msg.Action, resp)
|
||||
}
|
||||
|
||||
type wsDeleteColumnRequest struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (h *BoardHandlers) wsDeleteColumn(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
var req wsDeleteColumnRequest
|
||||
if err := msg.ParsePayload(&req); err != nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
|
||||
}
|
||||
if req.ID == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
|
||||
}
|
||||
if err := h.controller.DeleteColumn(ctx, dto.GetColumnRequest{ID: req.ID}); err != nil {
|
||||
h.logger.Error("failed to delete column (ws)", zap.Error(err), zap.String("column_id", req.ID))
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Column not found", nil)
|
||||
}
|
||||
return ws.NewResponse(msg.ID, msg.Action, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ type Column struct {
|
||||
Name string `json:"name"`
|
||||
Position int `json:"position"`
|
||||
State v1.TaskState `json:"state"` // Maps column to task state
|
||||
Color string `json:"color"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ func (r *SQLiteRepository) initSchema() error {
|
||||
name TEXT NOT NULL,
|
||||
position INTEGER DEFAULT 0,
|
||||
state TEXT DEFAULT 'TODO',
|
||||
color TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE
|
||||
@@ -187,6 +188,9 @@ func (r *SQLiteRepository) initSchema() error {
|
||||
if err := r.ensureColumn("tasks", "workspace_id", "TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.ensureColumn("columns", "color", "TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := r.ensureDefaultWorkspace(); err != nil {
|
||||
return err
|
||||
@@ -283,18 +287,19 @@ func (r *SQLiteRepository) ensureDefaultWorkspace() error {
|
||||
name string
|
||||
position int
|
||||
state string
|
||||
color string
|
||||
}
|
||||
columns := []columnSeed{
|
||||
{name: "Todo", position: 0, state: string(v1.TaskStateTODO)},
|
||||
{name: "In Progress", position: 1, state: string(v1.TaskStateInProgress)},
|
||||
{name: "Review", position: 2, state: string(v1.TaskStateReview)},
|
||||
{name: "Done", position: 3, state: string(v1.TaskStateCompleted)},
|
||||
{name: "Todo", position: 0, state: string(v1.TaskStateTODO), color: "bg-neutral-400"},
|
||||
{name: "In Progress", position: 1, state: string(v1.TaskStateInProgress), color: "bg-blue-500"},
|
||||
{name: "Review", position: 2, state: string(v1.TaskStateReview), color: "bg-yellow-500"},
|
||||
{name: "Done", position: 3, state: string(v1.TaskStateCompleted), color: "bg-green-500"},
|
||||
}
|
||||
for _, column := range columns {
|
||||
if _, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO columns (id, board_id, name, position, state, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, uuid.New().String(), boardID, column.name, column.position, column.state, now, now); err != nil {
|
||||
INSERT INTO columns (id, board_id, name, position, state, color, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, uuid.New().String(), boardID, column.name, column.position, column.state, column.color, now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -802,9 +807,9 @@ func (r *SQLiteRepository) CreateColumn(ctx context.Context, column *models.Colu
|
||||
column.UpdatedAt = now
|
||||
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO columns (id, board_id, name, position, state, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, column.ID, column.BoardID, column.Name, column.Position, column.State, column.CreatedAt, column.UpdatedAt)
|
||||
INSERT INTO columns (id, board_id, name, position, state, color, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, column.ID, column.BoardID, column.Name, column.Position, column.State, column.Color, column.CreatedAt, column.UpdatedAt)
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -814,9 +819,9 @@ func (r *SQLiteRepository) GetColumn(ctx context.Context, id string) (*models.Co
|
||||
column := &models.Column{}
|
||||
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, board_id, name, position, state, created_at, updated_at
|
||||
SELECT id, board_id, name, position, state, color, created_at, updated_at
|
||||
FROM columns WHERE id = ?
|
||||
`, id).Scan(&column.ID, &column.BoardID, &column.Name, &column.Position, &column.State, &column.CreatedAt, &column.UpdatedAt)
|
||||
`, id).Scan(&column.ID, &column.BoardID, &column.Name, &column.Position, &column.State, &column.Color, &column.CreatedAt, &column.UpdatedAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("column not found: %s", id)
|
||||
@@ -829,8 +834,8 @@ func (r *SQLiteRepository) UpdateColumn(ctx context.Context, column *models.Colu
|
||||
column.UpdatedAt = time.Now().UTC()
|
||||
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE columns SET name = ?, position = ?, state = ?, updated_at = ? WHERE id = ?
|
||||
`, column.Name, column.Position, column.State, column.UpdatedAt, column.ID)
|
||||
UPDATE columns SET name = ?, position = ?, state = ?, color = ?, updated_at = ? WHERE id = ?
|
||||
`, column.Name, column.Position, column.State, column.Color, column.UpdatedAt, column.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -859,7 +864,7 @@ func (r *SQLiteRepository) DeleteColumn(ctx context.Context, id string) error {
|
||||
// ListColumns returns all columns for a board
|
||||
func (r *SQLiteRepository) ListColumns(ctx context.Context, boardID string) ([]*models.Column, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, board_id, name, position, state, created_at, updated_at
|
||||
SELECT id, board_id, name, position, state, color, created_at, updated_at
|
||||
FROM columns WHERE board_id = ? ORDER BY position
|
||||
`, boardID)
|
||||
if err != nil {
|
||||
@@ -870,7 +875,7 @@ func (r *SQLiteRepository) ListColumns(ctx context.Context, boardID string) ([]*
|
||||
var result []*models.Column
|
||||
for rows.Next() {
|
||||
column := &models.Column{}
|
||||
err := rows.Scan(&column.ID, &column.BoardID, &column.Name, &column.Position, &column.State, &column.CreatedAt, &column.UpdatedAt)
|
||||
err := rows.Scan(&column.ID, &column.BoardID, &column.Name, &column.Position, &column.State, &column.Color, &column.CreatedAt, &column.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -92,6 +92,15 @@ type CreateColumnRequest struct {
|
||||
Name string `json:"name"`
|
||||
Position int `json:"position"`
|
||||
State v1.TaskState `json:"state"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
// UpdateColumnRequest contains the data for updating a column
|
||||
type UpdateColumnRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Position *int `json:"position,omitempty"`
|
||||
State *v1.TaskState `json:"state,omitempty"`
|
||||
Color *string `json:"color,omitempty"`
|
||||
}
|
||||
|
||||
// Task operations
|
||||
@@ -306,6 +315,7 @@ func (s *Service) CreateWorkspace(ctx context.Context, req *CreateWorkspaceReque
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.publishWorkspaceEvent(ctx, events.WorkspaceCreated, workspace)
|
||||
s.logger.Info("workspace created", zap.String("workspace_id", workspace.ID), zap.String("name", workspace.Name))
|
||||
return workspace, nil
|
||||
}
|
||||
@@ -335,16 +345,22 @@ func (s *Service) UpdateWorkspace(ctx context.Context, id string, req *UpdateWor
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.publishWorkspaceEvent(ctx, events.WorkspaceUpdated, workspace)
|
||||
s.logger.Info("workspace updated", zap.String("workspace_id", workspace.ID))
|
||||
return workspace, nil
|
||||
}
|
||||
|
||||
// DeleteWorkspace deletes a workspace
|
||||
func (s *Service) DeleteWorkspace(ctx context.Context, id string) error {
|
||||
workspace, err := s.repo.GetWorkspace(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.repo.DeleteWorkspace(ctx, id); err != nil {
|
||||
s.logger.Error("failed to delete workspace", zap.String("workspace_id", id), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
s.publishWorkspaceEvent(ctx, events.WorkspaceDeleted, workspace)
|
||||
s.logger.Info("workspace deleted", zap.String("workspace_id", id))
|
||||
return nil
|
||||
}
|
||||
@@ -370,6 +386,7 @@ func (s *Service) CreateBoard(ctx context.Context, req *CreateBoardRequest) (*mo
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.publishBoardEvent(ctx, events.BoardCreated, board)
|
||||
s.logger.Info("board created", zap.String("board_id", board.ID), zap.String("name", board.Name))
|
||||
return board, nil
|
||||
}
|
||||
@@ -399,17 +416,23 @@ func (s *Service) UpdateBoard(ctx context.Context, id string, req *UpdateBoardRe
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.publishBoardEvent(ctx, events.BoardUpdated, board)
|
||||
s.logger.Info("board updated", zap.String("board_id", board.ID))
|
||||
return board, nil
|
||||
}
|
||||
|
||||
// DeleteBoard deletes a board
|
||||
func (s *Service) DeleteBoard(ctx context.Context, id string) error {
|
||||
board, err := s.repo.GetBoard(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.repo.DeleteBoard(ctx, id); err != nil {
|
||||
s.logger.Error("failed to delete board", zap.String("board_id", id), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
s.publishBoardEvent(ctx, events.BoardDeleted, board)
|
||||
s.logger.Info("board deleted", zap.String("board_id", id))
|
||||
return nil
|
||||
}
|
||||
@@ -423,12 +446,17 @@ func (s *Service) ListBoards(ctx context.Context, workspaceID string) ([]*models
|
||||
|
||||
// CreateColumn creates a new column
|
||||
func (s *Service) CreateColumn(ctx context.Context, req *CreateColumnRequest) (*models.Column, error) {
|
||||
color := req.Color
|
||||
if color == "" {
|
||||
color = "bg-neutral-400"
|
||||
}
|
||||
column := &models.Column{
|
||||
ID: uuid.New().String(),
|
||||
BoardID: req.BoardID,
|
||||
Name: req.Name,
|
||||
Position: req.Position,
|
||||
State: req.State,
|
||||
Color: color,
|
||||
}
|
||||
|
||||
if err := s.repo.CreateColumn(ctx, column); err != nil {
|
||||
@@ -436,6 +464,7 @@ func (s *Service) CreateColumn(ctx context.Context, req *CreateColumnRequest) (*
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.publishColumnEvent(ctx, events.ColumnCreated, column)
|
||||
s.logger.Info("column created",
|
||||
zap.String("column_id", column.ID),
|
||||
zap.String("board_id", column.BoardID),
|
||||
@@ -453,6 +482,55 @@ func (s *Service) ListColumns(ctx context.Context, boardID string) ([]*models.Co
|
||||
return s.repo.ListColumns(ctx, boardID)
|
||||
}
|
||||
|
||||
// UpdateColumn updates an existing column
|
||||
func (s *Service) UpdateColumn(ctx context.Context, id string, req *UpdateColumnRequest) (*models.Column, error) {
|
||||
column, err := s.repo.GetColumn(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if req.Name != nil {
|
||||
column.Name = *req.Name
|
||||
}
|
||||
if req.Position != nil {
|
||||
column.Position = *req.Position
|
||||
}
|
||||
if req.State != nil {
|
||||
column.State = *req.State
|
||||
}
|
||||
if req.Color != nil {
|
||||
column.Color = *req.Color
|
||||
}
|
||||
column.UpdatedAt = time.Now().UTC()
|
||||
|
||||
if err := s.repo.UpdateColumn(ctx, column); err != nil {
|
||||
s.logger.Error("failed to update column", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.publishColumnEvent(ctx, events.ColumnUpdated, column)
|
||||
s.logger.Info("column updated",
|
||||
zap.String("column_id", column.ID),
|
||||
zap.String("board_id", column.BoardID),
|
||||
zap.String("name", column.Name))
|
||||
return column, nil
|
||||
}
|
||||
|
||||
// DeleteColumn deletes an existing column
|
||||
func (s *Service) DeleteColumn(ctx context.Context, id string) error {
|
||||
column, err := s.repo.GetColumn(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.repo.DeleteColumn(ctx, id); err != nil {
|
||||
s.logger.Error("failed to delete column", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
s.publishColumnEvent(ctx, events.ColumnDeleted, column)
|
||||
s.logger.Info("column deleted", zap.String("column_id", id))
|
||||
return nil
|
||||
}
|
||||
|
||||
// publishTaskEvent publishes task events to the event bus
|
||||
func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task *models.Task, oldState *v1.TaskState) {
|
||||
if s.eventBus == nil {
|
||||
@@ -496,3 +574,74 @@ func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task *
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) publishWorkspaceEvent(ctx context.Context, eventType string, workspace *models.Workspace) {
|
||||
if s.eventBus == nil || workspace == nil {
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"id": workspace.ID,
|
||||
"name": workspace.Name,
|
||||
"description": workspace.Description,
|
||||
"owner_id": workspace.OwnerID,
|
||||
"created_at": workspace.CreatedAt.Format(time.RFC3339),
|
||||
"updated_at": workspace.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
event := bus.NewEvent(eventType, "task-service", data)
|
||||
if err := s.eventBus.Publish(ctx, eventType, event); err != nil {
|
||||
s.logger.Error("failed to publish workspace event",
|
||||
zap.String("event_type", eventType),
|
||||
zap.String("workspace_id", workspace.ID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) publishBoardEvent(ctx context.Context, eventType string, board *models.Board) {
|
||||
if s.eventBus == nil || board == nil {
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"id": board.ID,
|
||||
"workspace_id": board.WorkspaceID,
|
||||
"name": board.Name,
|
||||
"description": board.Description,
|
||||
"created_at": board.CreatedAt.Format(time.RFC3339),
|
||||
"updated_at": board.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
event := bus.NewEvent(eventType, "task-service", data)
|
||||
if err := s.eventBus.Publish(ctx, eventType, event); err != nil {
|
||||
s.logger.Error("failed to publish board event",
|
||||
zap.String("event_type", eventType),
|
||||
zap.String("board_id", board.ID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) publishColumnEvent(ctx context.Context, eventType string, column *models.Column) {
|
||||
if s.eventBus == nil || column == nil {
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"id": column.ID,
|
||||
"board_id": column.BoardID,
|
||||
"name": column.Name,
|
||||
"position": column.Position,
|
||||
"state": string(column.State),
|
||||
"color": column.Color,
|
||||
"created_at": column.CreatedAt.Format(time.RFC3339),
|
||||
"updated_at": column.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
event := bus.NewEvent(eventType, "task-service", data)
|
||||
if err := s.eventBus.Publish(ctx, eventType, event); err != nil {
|
||||
s.logger.Error("failed to publish column event",
|
||||
zap.String("event_type", eventType),
|
||||
zap.String("column_id", column.ID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ const (
|
||||
ActionColumnList = "column.list"
|
||||
ActionColumnCreate = "column.create"
|
||||
ActionColumnGet = "column.get"
|
||||
ActionColumnUpdate = "column.update"
|
||||
ActionColumnDelete = "column.delete"
|
||||
|
||||
// Task actions
|
||||
ActionTaskList = "task.list"
|
||||
@@ -69,6 +71,15 @@ const (
|
||||
ActionACPHeartbeat = "acp.heartbeat"
|
||||
ActionTaskUpdated = "task.updated"
|
||||
ActionAgentUpdated = "agent.updated"
|
||||
ActionWorkspaceCreated = "workspace.created"
|
||||
ActionWorkspaceUpdated = "workspace.updated"
|
||||
ActionWorkspaceDeleted = "workspace.deleted"
|
||||
ActionBoardCreated = "board.created"
|
||||
ActionBoardUpdated = "board.updated"
|
||||
ActionBoardDeleted = "board.deleted"
|
||||
ActionColumnCreated = "column.created"
|
||||
ActionColumnUpdated = "column.updated"
|
||||
ActionColumnDeleted = "column.deleted"
|
||||
)
|
||||
|
||||
// Error codes
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
'use server';
|
||||
|
||||
import { getBackendConfig } from '@/lib/config';
|
||||
import type {
|
||||
Board,
|
||||
Column,
|
||||
ListBoardsResponse,
|
||||
ListColumnsResponse,
|
||||
ListWorkspacesResponse,
|
||||
Workspace,
|
||||
} from '@/lib/types/http';
|
||||
|
||||
const { apiBaseUrl } = getBackendConfig();
|
||||
|
||||
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
return undefined as T;
|
||||
}
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
export async function listWorkspacesAction(): Promise<ListWorkspacesResponse> {
|
||||
return fetchJson<ListWorkspacesResponse>(`${apiBaseUrl}/api/v1/workspaces`);
|
||||
}
|
||||
|
||||
export async function getWorkspaceAction(id: string): Promise<Workspace> {
|
||||
return fetchJson<Workspace>(`${apiBaseUrl}/api/v1/workspaces/${id}`);
|
||||
}
|
||||
|
||||
export async function createWorkspaceAction(payload: { name: string; description?: string }) {
|
||||
return fetchJson<Workspace>(`${apiBaseUrl}/api/v1/workspaces`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateWorkspaceAction(id: string, payload: { name?: string; description?: string }) {
|
||||
return fetchJson<Workspace>(`${apiBaseUrl}/api/v1/workspaces/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteWorkspaceAction(id: string) {
|
||||
await fetchJson<void>(`${apiBaseUrl}/api/v1/workspaces/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function listBoardsAction(workspaceId: string): Promise<ListBoardsResponse> {
|
||||
const url = new URL(`${apiBaseUrl}/api/v1/boards`);
|
||||
url.searchParams.set('workspace_id', workspaceId);
|
||||
return fetchJson<ListBoardsResponse>(url.toString());
|
||||
}
|
||||
|
||||
export async function createBoardAction(payload: { workspace_id: string; name: string; description?: string }) {
|
||||
return fetchJson<Board>(`${apiBaseUrl}/api/v1/boards`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateBoardAction(id: string, payload: { name?: string; description?: string }) {
|
||||
return fetchJson<Board>(`${apiBaseUrl}/api/v1/boards/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteBoardAction(id: string) {
|
||||
await fetchJson<void>(`${apiBaseUrl}/api/v1/boards/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function listColumnsAction(boardId: string): Promise<ListColumnsResponse> {
|
||||
return fetchJson<ListColumnsResponse>(`${apiBaseUrl}/api/v1/boards/${boardId}/columns`);
|
||||
}
|
||||
|
||||
export async function createColumnAction(payload: {
|
||||
board_id: string;
|
||||
name: string;
|
||||
position: number;
|
||||
state: string;
|
||||
color: string;
|
||||
}) {
|
||||
return fetchJson<Column>(`${apiBaseUrl}/api/v1/boards/${payload.board_id}/columns`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: payload.name,
|
||||
position: payload.position,
|
||||
state: payload.state,
|
||||
color: payload.color,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateColumnAction(id: string, payload: Partial<Pick<Column, 'name' | 'position' | 'state' | 'color'>>) {
|
||||
return fetchJson<Column>(`${apiBaseUrl}/api/v1/columns/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteColumnAction(id: string) {
|
||||
await fetchJson<void>(`${apiBaseUrl}/api/v1/columns/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { WebSocketConnector } from "@/components/ws-connector";
|
||||
import { ToastProvider } from "@/components/toast-provider";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Kandev - AI Kanban",
|
||||
@@ -19,8 +20,10 @@ export default function RootLayout({
|
||||
<body className="antialiased font-sans">
|
||||
<StateProvider>
|
||||
<ThemeProvider>
|
||||
<WebSocketConnector />
|
||||
{children}
|
||||
<ToastProvider>
|
||||
<WebSocketConnector />
|
||||
{children}
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</StateProvider>
|
||||
</body>
|
||||
|
||||
+29
-3
@@ -4,10 +4,23 @@ import { fetchBoardSnapshot, fetchBoards, fetchWorkspaces } from '@/lib/ssr/http
|
||||
import { snapshotToState } from '@/lib/ssr/mapper';
|
||||
|
||||
// Server Component: runs on the server for SSR and data hydration.
|
||||
export default async function Page() {
|
||||
type PageProps = {
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
try {
|
||||
const resolvedParams = searchParams ? await searchParams : {};
|
||||
const workspaceParam = resolvedParams.workspaceId;
|
||||
const boardParam = resolvedParams.boardId;
|
||||
const workspaceId = Array.isArray(workspaceParam) ? workspaceParam[0] : workspaceParam;
|
||||
const boardIdParam = Array.isArray(boardParam) ? boardParam[0] : boardParam;
|
||||
|
||||
const workspaces = await fetchWorkspaces();
|
||||
const activeWorkspaceId = workspaces.workspaces[0]?.id ?? null;
|
||||
const activeWorkspaceId =
|
||||
workspaces.workspaces.find((workspace) => workspace.id === workspaceId)?.id ??
|
||||
workspaces.workspaces[0]?.id ??
|
||||
null;
|
||||
let initialState = {
|
||||
workspaces: {
|
||||
items: workspaces.workspaces.map((workspace) => ({
|
||||
@@ -28,7 +41,20 @@ export default async function Page() {
|
||||
}
|
||||
|
||||
const boardList = await fetchBoards(activeWorkspaceId);
|
||||
const boardId = boardList.boards[0]?.id;
|
||||
const boardId =
|
||||
boardList.boards.find((board) => board.id === boardIdParam)?.id ?? boardList.boards[0]?.id;
|
||||
initialState = {
|
||||
...initialState,
|
||||
boards: {
|
||||
items: boardList.boards.map((board) => ({
|
||||
id: board.id,
|
||||
workspaceId: board.workspace_id,
|
||||
name: board.name,
|
||||
})),
|
||||
activeId: boardId ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
if (!boardId) {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
'use client';
|
||||
import { IconArrowLeft } from '@tabler/icons-react';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { SettingsAppSidebar } from '@/components/settings/settings-app-sidebar';
|
||||
import { SettingsLayoutClient } from '@/components/settings/settings-layout-client';
|
||||
import { StateHydrator } from '@/components/state-hydrator';
|
||||
import { fetchWorkspaces } from '@/lib/ssr/http';
|
||||
|
||||
export default function SettingsLayout({
|
||||
children,
|
||||
@@ -15,27 +8,31 @@ export default function SettingsLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<SettingsAppSidebar />
|
||||
<SidebarInset>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/" className="flex items-center gap-2">
|
||||
<IconArrowLeft className="h-4 w-4" />
|
||||
Home
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
|
||||
{children}
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
<SettingsLayoutServer>{children}</SettingsLayoutServer>
|
||||
);
|
||||
}
|
||||
|
||||
async function SettingsLayoutServer({ children }: { children: React.ReactNode }) {
|
||||
let initialState = {};
|
||||
try {
|
||||
const workspaces = await fetchWorkspaces();
|
||||
initialState = {
|
||||
workspaces: {
|
||||
items: workspaces.workspaces.map((workspace) => ({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
})),
|
||||
activeId: workspaces.workspaces[0]?.id ?? null,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
initialState = {};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StateHydrator initialState={initialState} />
|
||||
<SettingsLayoutClient>{children}</SettingsLayoutClient>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,379 +1,24 @@
|
||||
'use client';
|
||||
import { getWorkspaceAction, listBoardsAction, listColumnsAction } from '@/app/actions/workspaces';
|
||||
import { WorkspaceEditClient } from '@/app/settings/workspace/workspace-edit-client';
|
||||
|
||||
import { use, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { IconGitBranch, IconFolder, IconPlus, IconTrash } from '@tabler/icons-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { SettingsSection } from '@/components/settings/settings-section';
|
||||
import { RepositoryCard } from '@/components/settings/repository-card';
|
||||
import { ContextCard } from '@/components/settings/context-card';
|
||||
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
|
||||
import type { Workspace, Repository, Context } from '@/lib/settings/types';
|
||||
export default async function WorkspaceEditPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
let workspace = null;
|
||||
let boardsWithColumns: Array<Awaited<ReturnType<typeof listBoardsAction>>['boards'][number] & { columns: Awaited<ReturnType<typeof listColumnsAction>>['columns'] }> = [];
|
||||
|
||||
export default function WorkspaceEditPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [workspace, setWorkspace] = useState<Workspace | undefined>(
|
||||
SETTINGS_DATA.workspaces.find((w) => w.id === id)
|
||||
);
|
||||
const [isAddingRepo, setIsAddingRepo] = useState(false);
|
||||
const [isAddingContext, setIsAddingContext] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteConfirmText, setDeleteConfirmText] = useState('');
|
||||
|
||||
if (!workspace) {
|
||||
return (
|
||||
<div>
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">Workspace not found</p>
|
||||
<Button className="mt-4" onClick={() => router.push('/settings/workspace/new')}>
|
||||
Create New Workspace
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
try {
|
||||
workspace = await getWorkspaceAction(id);
|
||||
const boards = await listBoardsAction(id);
|
||||
boardsWithColumns = await Promise.all(
|
||||
boards.boards.map(async (board) => {
|
||||
const columns = await listColumnsAction(board.id);
|
||||
return { ...board, columns: columns.columns };
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
workspace = null;
|
||||
boardsWithColumns = [];
|
||||
}
|
||||
|
||||
const handleUpdateWorkspaceName = (name: string) => {
|
||||
setWorkspace({ ...workspace, name });
|
||||
};
|
||||
|
||||
const handleAddRepository = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const newRepo: Repository = {
|
||||
id: crypto.randomUUID(),
|
||||
name: formData.get('name') as string,
|
||||
path: formData.get('path') as string,
|
||||
setupScript: formData.get('setupScript') as string,
|
||||
cleanupScript: formData.get('cleanupScript') as string,
|
||||
customScripts: [],
|
||||
};
|
||||
setWorkspace({
|
||||
...workspace,
|
||||
repositories: [...workspace.repositories, newRepo],
|
||||
});
|
||||
setIsAddingRepo(false);
|
||||
};
|
||||
|
||||
const handleUpdateRepository = (repo: Repository) => {
|
||||
setWorkspace({
|
||||
...workspace,
|
||||
repositories: workspace.repositories.map((r) => (r.id === repo.id ? repo : r)),
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteRepository = (id: string) => {
|
||||
setWorkspace({
|
||||
...workspace,
|
||||
repositories: workspace.repositories.filter((r) => r.id !== id),
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddContext = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const newContext: Context = {
|
||||
id: crypto.randomUUID(),
|
||||
name: formData.get('name') as string,
|
||||
columns: [
|
||||
{ id: 'todo', title: 'To Do', color: 'bg-neutral-400' },
|
||||
{ id: 'in-progress', title: 'In Progress', color: 'bg-blue-500' },
|
||||
{ id: 'done', title: 'Done', color: 'bg-green-500' },
|
||||
],
|
||||
};
|
||||
setWorkspace({
|
||||
...workspace,
|
||||
contexts: [...workspace.contexts, newContext],
|
||||
});
|
||||
setIsAddingContext(false);
|
||||
};
|
||||
|
||||
const handleUpdateContext = (context: Context) => {
|
||||
setWorkspace({
|
||||
...workspace,
|
||||
contexts: workspace.contexts.map((c) => (c.id === context.id ? context : c)),
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteContext = (id: string) => {
|
||||
setWorkspace({
|
||||
...workspace,
|
||||
contexts: workspace.contexts.filter((c) => c.id !== id),
|
||||
});
|
||||
};
|
||||
|
||||
const handleFolderPick = (callback: (path: string) => void) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.webkitdirectory = true;
|
||||
input.onchange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement | null;
|
||||
const files = target?.files;
|
||||
if (files && files[0]) {
|
||||
const fullPath = files[0].webkitRelativePath;
|
||||
const folderName = fullPath.split('/')[0];
|
||||
callback(`/${folderName}`);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const handleDeleteWorkspace = () => {
|
||||
if (deleteConfirmText === 'delete') {
|
||||
// In real app, this would delete from database
|
||||
router.push('/settings/general');
|
||||
setDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">{workspace.name}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Manage repositories and contexts for this workspace
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Workspace Name</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Input
|
||||
value={workspace.name}
|
||||
onChange={(e) => handleUpdateWorkspaceName(e.target.value)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingsSection
|
||||
icon={<IconGitBranch className="h-5 w-5" />}
|
||||
title="Repositories"
|
||||
description="Repositories in this workspace"
|
||||
action={
|
||||
<Button size="sm" onClick={() => setIsAddingRepo(true)}>
|
||||
<IconPlus className="h-4 w-4 mr-2" />
|
||||
Add Repository
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-3">
|
||||
{isAddingRepo && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={handleAddRepository} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Repository Name</Label>
|
||||
<Input name="name" placeholder="my-project" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Directory Path</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="repo-path"
|
||||
name="path"
|
||||
placeholder="/path/to/repository"
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
handleFolderPick((path) => {
|
||||
const input = document.getElementById('repo-path') as HTMLInputElement;
|
||||
if (input) input.value = path;
|
||||
})
|
||||
}
|
||||
>
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Setup Script</Label>
|
||||
<Textarea
|
||||
name="setupScript"
|
||||
placeholder="#!/bin/bash npm install"
|
||||
rows={3}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Cleanup Script</Label>
|
||||
<Textarea
|
||||
name="cleanupScript"
|
||||
placeholder="#!/bin/bash rm -rf node_modules"
|
||||
rows={3}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button type="button" variant="outline" onClick={() => setIsAddingRepo(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Add</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{workspace.repositories.map((repo) => (
|
||||
<RepositoryCard
|
||||
key={repo.id}
|
||||
repository={repo}
|
||||
onUpdate={handleUpdateRepository}
|
||||
onDelete={() => handleDeleteRepository(repo.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{workspace.repositories.length === 0 && !isAddingRepo && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">No repositories in this workspace</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingsSection
|
||||
icon={<IconFolder className="h-5 w-5" />}
|
||||
title="Contexts"
|
||||
description="Kanban view contexts for this workspace"
|
||||
action={
|
||||
<Button size="sm" onClick={() => setIsAddingContext(true)}>
|
||||
<IconPlus className="h-4 w-4 mr-2" />
|
||||
Add Context
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-3">
|
||||
{isAddingContext && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={handleAddContext} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Context Name</Label>
|
||||
<Input name="name" placeholder="Dev" required />
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsAddingContext(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Add</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{workspace.contexts.map((context) => (
|
||||
<ContextCard
|
||||
key={context.id}
|
||||
context={context}
|
||||
onUpdate={handleUpdateContext}
|
||||
onDelete={() => handleDeleteContext(context.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{workspace.contexts.length === 0 && !isAddingContext && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">No contexts in this workspace</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Danger Zone */}
|
||||
<Card className="border-destructive">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
||||
<CardDescription>
|
||||
Irreversible actions that will permanently delete this workspace
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Delete this workspace</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Once deleted, all repositories and contexts will be permanently removed
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="destructive" onClick={() => setDeleteDialogOpen(true)}>
|
||||
<IconTrash className="h-4 w-4 mr-2" />
|
||||
Delete Workspace
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Workspace</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the workspace "
|
||||
{workspace.name}" and all its data.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<p className="text-sm">
|
||||
Please type <span className="font-mono font-bold">delete</span> to confirm:
|
||||
</p>
|
||||
<Input
|
||||
value={deleteConfirmText}
|
||||
onChange={(e) => setDeleteConfirmText(e.target.value)}
|
||||
placeholder="delete"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteWorkspace}
|
||||
disabled={deleteConfirmText !== 'delete'}
|
||||
>
|
||||
Delete Workspace
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
return <WorkspaceEditClient workspace={workspace} boards={boardsWithColumns} />;
|
||||
}
|
||||
|
||||
@@ -1,116 +1,7 @@
|
||||
'use client';
|
||||
import { fetchWorkspaces } from '@/lib/ssr/http';
|
||||
import { WorkspacesPageClient } from '@/app/settings/workspace/workspaces-page-client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { IconFolder, IconPlus, IconChevronRight } from '@tabler/icons-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
|
||||
import type { Workspace } from '@/lib/settings/types';
|
||||
|
||||
export default function WorkspacesPage() {
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>(SETTINGS_DATA.workspaces);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [newWorkspaceName, setNewWorkspaceName] = useState('');
|
||||
|
||||
const handleAddWorkspace = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (newWorkspaceName.trim()) {
|
||||
const newWorkspace: Workspace = {
|
||||
id: crypto.randomUUID(),
|
||||
name: newWorkspaceName,
|
||||
repositories: [],
|
||||
contexts: [],
|
||||
};
|
||||
setWorkspaces([...workspaces, newWorkspace]);
|
||||
setNewWorkspaceName('');
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Workspaces</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Manage your workspaces with repositories and contexts
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setIsAdding(true)}>
|
||||
<IconPlus className="h-4 w-4 mr-2" />
|
||||
Add Workspace
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3">
|
||||
{isAdding && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={handleAddWorkspace} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="workspace-name">Workspace Name</Label>
|
||||
<Input
|
||||
id="workspace-name"
|
||||
value={newWorkspaceName}
|
||||
onChange={(e) => setNewWorkspaceName(e.target.value)}
|
||||
placeholder="My Workspace"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button type="button" variant="outline" onClick={() => setIsAdding(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Add Workspace</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{workspaces.map((workspace) => (
|
||||
<Link key={workspace.id} href={`/settings/workspace/${workspace.id}`}>
|
||||
<Card className="hover:bg-accent transition-colors cursor-pointer">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
<div className="p-2 bg-muted rounded-md">
|
||||
<IconFolder className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium">{workspace.name}</h4>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
|
||||
<span>{workspace.repositories.length} repositories</span>
|
||||
<span>{workspace.contexts.length} contexts</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<IconChevronRight className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{workspaces.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No workspaces configured. Add your first workspace to get started.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default async function WorkspacesPage() {
|
||||
const workspaces = await fetchWorkspaces();
|
||||
return <WorkspacesPageClient workspaces={workspaces.workspaces} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { IconGitBranch, IconPlus, IconTrash } from '@tabler/icons-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { SettingsSection } from '@/components/settings/settings-section';
|
||||
import { RepositoryCard } from '@/components/settings/repository-card';
|
||||
import { BoardCard } from '@/components/settings/board-card';
|
||||
import {
|
||||
createBoardAction,
|
||||
createColumnAction,
|
||||
deleteBoardAction,
|
||||
deleteColumnAction,
|
||||
deleteWorkspaceAction,
|
||||
updateBoardAction,
|
||||
updateColumnAction,
|
||||
updateWorkspaceAction,
|
||||
} from '@/app/actions/workspaces';
|
||||
import type { Board, Column, Workspace } from '@/lib/types/http';
|
||||
import type { Repository } from '@/lib/settings/types';
|
||||
import { useRequest } from '@/lib/http/use-request';
|
||||
import { useToast } from '@/components/toast-provider';
|
||||
import { UnsavedChangesBadge, UnsavedSaveButton } from '@/components/settings/unsaved-indicator';
|
||||
|
||||
type BoardWithColumns = Board & { columns: Column[] };
|
||||
|
||||
type WorkspaceEditClientProps = {
|
||||
workspace: Workspace | null;
|
||||
boards: BoardWithColumns[];
|
||||
};
|
||||
|
||||
export function WorkspaceEditClient({ workspace, boards }: WorkspaceEditClientProps) {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [currentWorkspace, setCurrentWorkspace] = useState<Workspace | null>(workspace);
|
||||
const [workspaceNameDraft, setWorkspaceNameDraft] = useState(workspace?.name ?? '');
|
||||
const [boardItems, setBoardItems] = useState<BoardWithColumns[]>(boards);
|
||||
const [savedWorkspaceName, setSavedWorkspaceName] = useState(workspace?.name ?? '');
|
||||
const [savedBoardItems, setSavedBoardItems] = useState<BoardWithColumns[]>(boards);
|
||||
const [repositories, setRepositories] = useState<Repository[]>([]);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteConfirmText, setDeleteConfirmText] = useState('');
|
||||
const saveWorkspaceRequest = useRequest(updateWorkspaceAction);
|
||||
const deleteWorkspaceRequest = useRequest(deleteWorkspaceAction);
|
||||
|
||||
const handleSaveWorkspaceName = async () => {
|
||||
if (!currentWorkspace) return;
|
||||
const trimmed = workspaceNameDraft.trim();
|
||||
if (!trimmed || trimmed === currentWorkspace.name) return;
|
||||
try {
|
||||
const updated = await saveWorkspaceRequest.run(currentWorkspace.id, { name: trimmed });
|
||||
setCurrentWorkspace((prev) => (prev ? { ...prev, ...updated } : prev));
|
||||
setSavedWorkspaceName(updated.name);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to save workspace',
|
||||
description: error instanceof Error ? error.message : 'Request failed',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const cloneBoard = (board: BoardWithColumns): BoardWithColumns => ({
|
||||
...board,
|
||||
columns: board.columns.map((column) => ({ ...column })),
|
||||
});
|
||||
|
||||
const savedBoardsById = useMemo(() => {
|
||||
return new Map(savedBoardItems.map((board) => [board.id, board]));
|
||||
}, [savedBoardItems]);
|
||||
|
||||
const isBoardNameDirty = (board: BoardWithColumns) => {
|
||||
const saved = savedBoardsById.get(board.id);
|
||||
if (!saved) return true;
|
||||
if (board.name !== saved.name || board.description !== saved.description) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const areColumnsDirty = (board: BoardWithColumns) => {
|
||||
const saved = savedBoardsById.get(board.id);
|
||||
if (!saved) {
|
||||
return board.columns.length > 0;
|
||||
}
|
||||
if (board.columns.length !== saved.columns.length) return true;
|
||||
const savedColumns = new Map(saved.columns.map((column) => [column.id, column]));
|
||||
for (const column of board.columns) {
|
||||
const savedColumn = savedColumns.get(column.id);
|
||||
if (!savedColumn) return true;
|
||||
if (
|
||||
column.name !== savedColumn.name ||
|
||||
column.color !== savedColumn.color ||
|
||||
column.position !== savedColumn.position ||
|
||||
column.state !== savedColumn.state
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const isWorkspaceDirty = workspaceNameDraft.trim() !== savedWorkspaceName;
|
||||
|
||||
const handleAddRepository = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const newRepo: Repository = {
|
||||
id: crypto.randomUUID(),
|
||||
name: formData.get('name') as string,
|
||||
path: formData.get('path') as string,
|
||||
setupScript: formData.get('setupScript') as string,
|
||||
cleanupScript: formData.get('cleanupScript') as string,
|
||||
customScripts: [],
|
||||
};
|
||||
setRepositories((prev) => [...prev, newRepo]);
|
||||
};
|
||||
|
||||
const handleUpdateRepository = (repo: Repository) => {
|
||||
setRepositories((prev) => prev.map((item) => (item.id === repo.id ? repo : item)));
|
||||
};
|
||||
|
||||
const handleDeleteRepository = (id: string) => {
|
||||
setRepositories((prev) => prev.filter((repo) => repo.id !== id));
|
||||
};
|
||||
|
||||
const handleAddBoard = () => {
|
||||
if (!currentWorkspace) return;
|
||||
const draftBoardId = `temp-${crypto.randomUUID()}`;
|
||||
const draftBoard: BoardWithColumns = {
|
||||
id: draftBoardId,
|
||||
workspace_id: currentWorkspace.id,
|
||||
name: 'New Board',
|
||||
description: '',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
columns: [
|
||||
{
|
||||
id: `temp-col-${crypto.randomUUID()}`,
|
||||
board_id: draftBoardId,
|
||||
name: 'Todo',
|
||||
position: 0,
|
||||
state: 'TODO',
|
||||
color: 'bg-cyan-500',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
{
|
||||
id: `temp-col-${crypto.randomUUID()}`,
|
||||
board_id: draftBoardId,
|
||||
name: 'In Progress',
|
||||
position: 1,
|
||||
state: 'IN_PROGRESS',
|
||||
color: 'bg-yellow-500',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
{
|
||||
id: `temp-col-${crypto.randomUUID()}`,
|
||||
board_id: draftBoardId,
|
||||
name: 'To Review',
|
||||
position: 2,
|
||||
state: 'REVIEW',
|
||||
color: 'bg-green-500',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
{
|
||||
id: `temp-col-${crypto.randomUUID()}`,
|
||||
board_id: draftBoardId,
|
||||
name: 'Done',
|
||||
position: 3,
|
||||
state: 'COMPLETED',
|
||||
color: 'bg-indigo-500',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
],
|
||||
};
|
||||
setBoardItems((prev) => [draftBoard, ...prev]);
|
||||
};
|
||||
|
||||
const handleUpdateBoard = (boardId: string, updates: { name?: string; description?: string }) => {
|
||||
setBoardItems((prev) =>
|
||||
prev.map((board) => (board.id === boardId ? { ...board, ...updates } : board))
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteBoard = async (boardId: string) => {
|
||||
if (boardId.startsWith('temp-')) {
|
||||
setBoardItems((prev) => prev.filter((board) => board.id !== boardId));
|
||||
return;
|
||||
}
|
||||
await deleteBoardAction(boardId);
|
||||
setBoardItems((prev) => prev.filter((board) => board.id !== boardId));
|
||||
setSavedBoardItems((prev) => prev.filter((board) => board.id !== boardId));
|
||||
};
|
||||
|
||||
const handleCreateColumn = (
|
||||
boardId: string,
|
||||
column: Omit<Column, 'id' | 'board_id' | 'created_at' | 'updated_at'>
|
||||
) => {
|
||||
const draftColumn: Column = {
|
||||
id: `temp-col-${crypto.randomUUID()}`,
|
||||
board_id: boardId,
|
||||
name: column.name,
|
||||
position: column.position,
|
||||
state: column.state,
|
||||
color: column.color,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
};
|
||||
setBoardItems((prev) =>
|
||||
prev.map((board) =>
|
||||
board.id === boardId ? { ...board, columns: [...board.columns, draftColumn] } : board
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleUpdateColumn = (boardId: string, columnId: string, updates: Partial<Column>) => {
|
||||
setBoardItems((prev) =>
|
||||
prev.map((board) =>
|
||||
board.id === boardId
|
||||
? {
|
||||
...board,
|
||||
columns: board.columns.map((column) => (column.id === columnId ? { ...column, ...updates } : column)),
|
||||
}
|
||||
: board
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteColumn = async (boardId: string, columnId: string) => {
|
||||
if (boardId.startsWith('temp-')) {
|
||||
setBoardItems((prev) =>
|
||||
prev.map((board) =>
|
||||
board.id === boardId ? { ...board, columns: board.columns.filter((column) => column.id !== columnId) } : board
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
await deleteColumnAction(columnId);
|
||||
setBoardItems((prev) =>
|
||||
prev.map((board) =>
|
||||
board.id === boardId ? { ...board, columns: board.columns.filter((column) => column.id !== columnId) } : board
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleReorderColumns = (boardId: string, columns: Column[]) => {
|
||||
setBoardItems((prev) =>
|
||||
prev.map((board) => (board.id === boardId ? { ...board, columns } : board))
|
||||
);
|
||||
};
|
||||
|
||||
const handleSaveBoard = async (boardId: string) => {
|
||||
const board = boardItems.find((item) => item.id === boardId);
|
||||
if (!board) return;
|
||||
if (boardId.startsWith('temp-')) {
|
||||
const name = board.name.trim() || 'New Board';
|
||||
const createdBoard = await createBoardAction({
|
||||
workspace_id: currentWorkspace?.id ?? board.workspace_id,
|
||||
name,
|
||||
});
|
||||
const createdColumns = await Promise.all(
|
||||
board.columns.map((column, index) =>
|
||||
createColumnAction({
|
||||
board_id: createdBoard.id,
|
||||
name: column.name.trim() || 'New Column',
|
||||
position: column.position ?? index,
|
||||
state: column.state,
|
||||
color: column.color,
|
||||
})
|
||||
)
|
||||
);
|
||||
const nextBoard = {
|
||||
...createdBoard,
|
||||
columns: createdColumns,
|
||||
};
|
||||
setBoardItems((prev) =>
|
||||
prev.map((item) => (item.id === boardId ? nextBoard : item))
|
||||
);
|
||||
setSavedBoardItems((prev) => [cloneBoard(nextBoard), ...prev]);
|
||||
return;
|
||||
}
|
||||
const updates: { name?: string; description?: string } = {};
|
||||
if (board.name.trim()) {
|
||||
updates.name = board.name.trim();
|
||||
}
|
||||
if (Object.keys(updates).length) {
|
||||
await updateBoardAction(boardId, updates);
|
||||
}
|
||||
const nextColumns = await Promise.all(
|
||||
board.columns.map((column, index) => {
|
||||
if (column.id.startsWith('temp-col-')) {
|
||||
return createColumnAction({
|
||||
board_id: boardId,
|
||||
name: column.name.trim() || 'New Column',
|
||||
position: column.position ?? index,
|
||||
state: column.state,
|
||||
color: column.color,
|
||||
});
|
||||
}
|
||||
return updateColumnAction(column.id, {
|
||||
name: column.name,
|
||||
color: column.color,
|
||||
position: column.position ?? index,
|
||||
state: column.state,
|
||||
});
|
||||
})
|
||||
);
|
||||
setBoardItems((prev) =>
|
||||
prev.map((item) => (item.id === boardId ? { ...item, columns: nextColumns } : item))
|
||||
);
|
||||
setSavedBoardItems((prev) =>
|
||||
prev.some((item) => item.id === boardId)
|
||||
? prev.map((item) =>
|
||||
item.id === boardId ? cloneBoard({ ...board, columns: nextColumns }) : item
|
||||
)
|
||||
: [...prev, cloneBoard({ ...board, columns: nextColumns })]
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteWorkspace = async () => {
|
||||
if (deleteConfirmText !== 'delete') return;
|
||||
try {
|
||||
await deleteWorkspaceRequest.run(currentWorkspace.id);
|
||||
router.push('/settings/workspace');
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to delete workspace',
|
||||
description: error instanceof Error ? error.message : 'Request failed',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!currentWorkspace) {
|
||||
return (
|
||||
<div>
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">Workspace not found</p>
|
||||
<Button className="mt-4" onClick={() => router.push('/settings/workspace')}>
|
||||
Back to Workspaces
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">{currentWorkspace.name}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Manage repositories and boards for this workspace
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span>Workspace Name</span>
|
||||
{isWorkspaceDirty && <UnsavedChangesBadge />}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={workspaceNameDraft}
|
||||
onChange={(e) => setWorkspaceNameDraft(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<UnsavedSaveButton
|
||||
isDirty={isWorkspaceDirty}
|
||||
isLoading={saveWorkspaceRequest.isLoading}
|
||||
status={saveWorkspaceRequest.status}
|
||||
onClick={handleSaveWorkspaceName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingsSection
|
||||
icon={<IconGitBranch className="h-5 w-5" />}
|
||||
title="Repositories"
|
||||
description="Repositories in this workspace"
|
||||
>
|
||||
<div className="grid gap-3">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={handleAddRepository} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Repository Name</Label>
|
||||
<Input name="name" placeholder="my-project" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Directory Path</Label>
|
||||
<Input name="path" placeholder="/path/to/repository" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Setup Script</Label>
|
||||
<Input name="setupScript" placeholder="npm install" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Cleanup Script</Label>
|
||||
<Input name="cleanupScript" placeholder="npm run cleanup" />
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit">Add Repository</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{repositories.map((repo) => (
|
||||
<RepositoryCard
|
||||
key={repo.id}
|
||||
repository={repo}
|
||||
onUpdate={handleUpdateRepository}
|
||||
onDelete={() => handleDeleteRepository(repo.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingsSection
|
||||
icon={<IconGitBranch className="h-5 w-5" />}
|
||||
title="Boards"
|
||||
description="Boards and columns in this workspace"
|
||||
action={
|
||||
<Button size="sm" onClick={handleAddBoard}>
|
||||
<IconPlus className="h-4 w-4 mr-2" />
|
||||
Add Board
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-3">
|
||||
{boardItems.map((board) => (
|
||||
<BoardCard
|
||||
key={board.id}
|
||||
board={board}
|
||||
columns={board.columns}
|
||||
isBoardDirty={isBoardNameDirty(board)}
|
||||
areColumnsDirty={areColumnsDirty(board)}
|
||||
onUpdateBoard={(updates) => handleUpdateBoard(board.id, updates)}
|
||||
onDeleteBoard={() => handleDeleteBoard(board.id)}
|
||||
onCreateColumn={(column) => handleCreateColumn(board.id, column)}
|
||||
onUpdateColumn={(columnId, updates) => handleUpdateColumn(board.id, columnId, updates)}
|
||||
onDeleteColumn={(columnId) => handleDeleteColumn(board.id, columnId)}
|
||||
onReorderColumns={(columns) => handleReorderColumns(board.id, columns)}
|
||||
onSaveBoard={() => handleSaveBoard(board.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Delete Workspace</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Deleting this workspace will remove all boards and tasks associated with it.
|
||||
</p>
|
||||
<Button variant="destructive" onClick={() => setDeleteDialogOpen(true)}>
|
||||
<IconTrash className="h-4 w-4 mr-2" />
|
||||
Delete Workspace
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Workspace</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will delete all boards and tasks. This action cannot be undone. Type "delete" to confirm.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={deleteConfirmText}
|
||||
onChange={(e) => setDeleteConfirmText(e.target.value)}
|
||||
placeholder="delete"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDeleteWorkspace}>
|
||||
Delete Workspace
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { IconFolder, IconPlus, IconChevronRight } from '@tabler/icons-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { createWorkspaceAction } from '@/app/actions/workspaces';
|
||||
import type { Workspace } from '@/lib/types/http';
|
||||
import { useRequest } from '@/lib/http/use-request';
|
||||
import { useToast } from '@/components/toast-provider';
|
||||
import { RequestIndicator } from '@/components/request-indicator';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
|
||||
type WorkspacesPageClientProps = {
|
||||
workspaces: Workspace[];
|
||||
};
|
||||
|
||||
export function WorkspacesPageClient({ workspaces }: WorkspacesPageClientProps) {
|
||||
const items = useAppStore((state) => state.workspaces.items);
|
||||
const setWorkspaces = useAppStore((state) => state.setWorkspaces);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [newWorkspaceName, setNewWorkspaceName] = useState('');
|
||||
const createRequest = useRequest(createWorkspaceAction);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (!items.length && workspaces.length) {
|
||||
setWorkspaces(
|
||||
workspaces.map((workspace) => ({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}, [items.length, setWorkspaces, workspaces]);
|
||||
|
||||
const handleAddWorkspace = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newWorkspaceName.trim()) return;
|
||||
try {
|
||||
const created = await createRequest.run({ name: newWorkspaceName.trim() });
|
||||
setWorkspaces([
|
||||
{ id: created.id, name: created.name },
|
||||
...items.map((workspace) => ({ id: workspace.id, name: workspace.name })),
|
||||
]);
|
||||
setNewWorkspaceName('');
|
||||
setIsAdding(false);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to create workspace',
|
||||
description: error instanceof Error ? error.message : 'Request failed',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Workspaces</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Manage your workspaces and boards
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setIsAdding(true)}>
|
||||
<IconPlus className="h-4 w-4 mr-2" />
|
||||
Add Workspace
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3">
|
||||
{isAdding && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={handleAddWorkspace} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="workspace-name">Workspace Name</Label>
|
||||
<Input
|
||||
id="workspace-name"
|
||||
value={newWorkspaceName}
|
||||
onChange={(e) => setNewWorkspaceName(e.target.value)}
|
||||
placeholder="My Workspace"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button type="button" variant="outline" onClick={() => setIsAdding(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<RequestIndicator status={createRequest.status} />
|
||||
<Button type="submit" disabled={createRequest.isLoading}>
|
||||
Add Workspace
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{items.map((workspace) => (
|
||||
<Link key={workspace.id} href={`/settings/workspace/${workspace.id}`}>
|
||||
<Card className="hover:bg-accent transition-colors cursor-pointer">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
<div className="p-2 bg-muted rounded-md">
|
||||
<IconFolder className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium">{workspace.name}</h4>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
|
||||
<span>Boards managed in this workspace</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<IconChevronRight className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{items.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No workspaces configured. Add your first workspace to get started.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState, useSyncExternalStore } from 'react';
|
||||
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
@@ -26,45 +26,8 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAppStore, useAppStoreApi } from '@/components/state-provider';
|
||||
|
||||
const VIEWS: Record<
|
||||
string,
|
||||
{
|
||||
label: string;
|
||||
columns: Column[];
|
||||
}
|
||||
> = {
|
||||
team: {
|
||||
label: 'Team view',
|
||||
columns: [
|
||||
{ id: 'backlog', title: 'Backlog', color: 'bg-neutral-400' },
|
||||
{ id: 'solution-design', title: 'Solution Design', color: 'bg-sky-500' },
|
||||
{ id: 'ready-for-dev', title: 'Ready for Dev', color: 'bg-indigo-500' },
|
||||
{ id: 'in-progress', title: 'In Progress', color: 'bg-blue-500' },
|
||||
{ id: 'review', title: 'Review', color: 'bg-yellow-500' },
|
||||
{ id: 'done', title: 'Done', color: 'bg-green-500' },
|
||||
],
|
||||
},
|
||||
user: {
|
||||
label: 'User view',
|
||||
columns: [
|
||||
{ id: 'todo', title: 'To Do', color: 'bg-neutral-400' },
|
||||
{ id: 'in-progress', title: 'In Progress', color: 'bg-blue-500' },
|
||||
{ id: 'review', title: 'Review', color: 'bg-yellow-500' },
|
||||
{ id: 'done', title: 'Done', color: 'bg-green-500' },
|
||||
],
|
||||
},
|
||||
architect: {
|
||||
label: 'Architect view',
|
||||
columns: [
|
||||
{ id: 'backlog', title: 'Backlog', color: 'bg-neutral-400' },
|
||||
{ id: 'high-level-design', title: 'High Level Design', color: 'bg-cyan-500' },
|
||||
{ id: 'low-level-design', title: 'Low Level Design', color: 'bg-violet-500' },
|
||||
{ id: 'review', title: 'Review', color: 'bg-yellow-500' },
|
||||
{ id: 'done', title: 'Done', color: 'bg-green-500' },
|
||||
],
|
||||
},
|
||||
};
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import type { ListBoardsResponse } from '@/lib/types/http';
|
||||
|
||||
const initialTasks: Task[] = [
|
||||
{ id: '1', title: 'Design database schema', status: 'todo' },
|
||||
@@ -76,11 +39,13 @@ export function KanbanBoard() {
|
||||
const [tasks, setTasks] = useState<Task[]>(initialTasks);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
||||
const [activeViewId, setActiveViewId] = useState('team');
|
||||
const router = useRouter();
|
||||
const kanban = useAppStore((state) => state.kanban);
|
||||
const workspaceState = useAppStore((state) => state.workspaces);
|
||||
const setActiveWorkspace = useAppStore((state) => state.setActiveWorkspace);
|
||||
const boardsState = useAppStore((state) => state.boards);
|
||||
const setActiveBoard = useAppStore((state) => state.setActiveBoard);
|
||||
const setBoards = useAppStore((state) => state.setBoards);
|
||||
const store = useAppStoreApi();
|
||||
|
||||
const isMounted = useSyncExternalStore(
|
||||
@@ -97,14 +62,15 @@ export function KanbanBoard() {
|
||||
})
|
||||
);
|
||||
|
||||
const activeView = VIEWS[activeViewId] ?? VIEWS.team;
|
||||
const backendColumns = useMemo<Column[]>(
|
||||
() =>
|
||||
kanban.columns.map((column) => ({
|
||||
id: column.id,
|
||||
title: column.title,
|
||||
color: 'bg-neutral-400',
|
||||
})),
|
||||
[...kanban.columns]
|
||||
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
|
||||
.map((column) => ({
|
||||
id: column.id,
|
||||
title: column.title,
|
||||
color: column.color || 'bg-neutral-400',
|
||||
})),
|
||||
[kanban.columns]
|
||||
);
|
||||
const backendTasks = useMemo<Task[]>(
|
||||
@@ -116,7 +82,7 @@ export function KanbanBoard() {
|
||||
})),
|
||||
[kanban.tasks]
|
||||
);
|
||||
const activeColumns = kanban.boardId ? backendColumns : activeView.columns;
|
||||
const activeColumns = kanban.boardId ? backendColumns : [];
|
||||
const visibleTasks = kanban.boardId ? backendTasks : tasks;
|
||||
const activeTask = useMemo(
|
||||
() => visibleTasks.find((task) => task.id === activeTaskId) ?? null,
|
||||
@@ -178,6 +144,33 @@ export function KanbanBoard() {
|
||||
return visibleTasks.filter((task) => task.status === columnId);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const workspaceId = workspaceState.activeId;
|
||||
if (!workspaceId) return;
|
||||
const client = getWebSocketClient();
|
||||
if (!client) return;
|
||||
client
|
||||
.request<ListBoardsResponse>('board.list', { workspace_id: workspaceId })
|
||||
.then((response) => {
|
||||
const boards = response.boards.map((board) => ({
|
||||
id: board.id,
|
||||
workspaceId: board.workspace_id,
|
||||
name: board.name,
|
||||
}));
|
||||
setBoards(boards);
|
||||
const nextBoardId = boards[0]?.id ?? null;
|
||||
setActiveBoard(nextBoardId);
|
||||
if (!nextBoardId) {
|
||||
store.getState().hydrate({
|
||||
kanban: { boardId: null, columns: [], tasks: [] },
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Ignore board list errors for now.
|
||||
});
|
||||
}, [setActiveBoard, setBoards, store, workspaceState.activeId]);
|
||||
|
||||
if (!isMounted) {
|
||||
return <div className="h-screen w-full bg-background" />;
|
||||
}
|
||||
@@ -186,11 +179,18 @@ export function KanbanBoard() {
|
||||
<div className="h-screen w-full flex flex-col bg-background">
|
||||
<header className="flex items-center justify-between p-4 pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold">KanDev.ai</h1>
|
||||
<Link href="/" className="text-2xl font-bold hover:opacity-80">
|
||||
KanDev.ai
|
||||
</Link>
|
||||
<ConnectionStatus />
|
||||
<Select
|
||||
value={workspaceState.activeId ?? ''}
|
||||
onValueChange={(value) => setActiveWorkspace(value || null)}
|
||||
onValueChange={(value) => {
|
||||
setActiveWorkspace(value || null);
|
||||
if (value) {
|
||||
router.push(`/?workspaceId=${value}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Select workspace" />
|
||||
@@ -205,14 +205,24 @@ export function KanbanBoard() {
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Select value={activeViewId} onValueChange={setActiveViewId}>
|
||||
<Select
|
||||
value={boardsState.activeId ?? ''}
|
||||
onValueChange={(value) => {
|
||||
setActiveBoard(value || null);
|
||||
if (value) {
|
||||
const workspaceId = boardsState.items.find((board) => board.id === value)?.workspaceId;
|
||||
const workspaceParam = workspaceId ? `&workspaceId=${workspaceId}` : '';
|
||||
router.push(`/?boardId=${value}${workspaceParam}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Select view" />
|
||||
<SelectValue placeholder="Select board" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(VIEWS).map(([id, view]) => (
|
||||
<SelectItem key={id} value={id}>
|
||||
{view.label}
|
||||
{boardsState.items.map((board) => (
|
||||
<SelectItem key={board.id} value={board.id}>
|
||||
{board.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -244,19 +254,25 @@ export function KanbanBoard() {
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<div className="flex-1 min-h-0 px-4 pb-4">
|
||||
<div
|
||||
className="grid gap-[3px] rounded-lg overflow-hidden h-full p-[2px]"
|
||||
style={{ gridTemplateColumns: `repeat(${activeColumns.length}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{activeColumns.map((column) => (
|
||||
<KanbanColumn
|
||||
key={column.id}
|
||||
column={column}
|
||||
tasks={getTasksForColumn(column.id)}
|
||||
onOpenTask={handleOpenTask}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{activeColumns.length === 0 ? (
|
||||
<div className="h-full rounded-lg border border-dashed border-border/60 flex items-center justify-center text-sm text-muted-foreground">
|
||||
No boards available yet.
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="grid gap-[3px] rounded-lg overflow-hidden h-full p-[2px]"
|
||||
style={{ gridTemplateColumns: `repeat(${activeColumns.length}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{activeColumns.map((column) => (
|
||||
<KanbanColumn
|
||||
key={column.id}
|
||||
column={column}
|
||||
tasks={getTasksForColumn(column.id)}
|
||||
onOpenTask={handleOpenTask}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeTask ? <KanbanCardPreview task={activeTask} /> : null}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { IconCheck, IconLoader2, IconX } from '@tabler/icons-react';
|
||||
import type { RequestStatus } from '@/lib/http/use-request';
|
||||
|
||||
type RequestIndicatorProps = {
|
||||
status: RequestStatus;
|
||||
};
|
||||
|
||||
export function RequestIndicator({ status }: RequestIndicatorProps) {
|
||||
if (status === 'loading') {
|
||||
return <IconLoader2 className="h-4 w-4 animate-spin text-muted-foreground" />;
|
||||
}
|
||||
if (status === 'success') {
|
||||
return <IconCheck className="h-4 w-4 text-emerald-500" />;
|
||||
}
|
||||
if (status === 'error') {
|
||||
return <IconX className="h-4 w-4 text-destructive" />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState, useSyncExternalStore } from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import {
|
||||
IconGripVertical,
|
||||
IconTrash,
|
||||
IconPlus,
|
||||
} from '@tabler/icons-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import type { Board, Column } from '@/lib/types/http';
|
||||
import { useRequest } from '@/lib/http/use-request';
|
||||
import { useToast } from '@/components/toast-provider';
|
||||
import { UnsavedChangesBadge, UnsavedSaveButton } from '@/components/settings/unsaved-indicator';
|
||||
|
||||
type BoardCardProps = {
|
||||
board: Board;
|
||||
columns: Column[];
|
||||
isBoardDirty: boolean;
|
||||
areColumnsDirty: boolean;
|
||||
onUpdateBoard: (updates: { name?: string; description?: string }) => void;
|
||||
onDeleteBoard: () => Promise<unknown>;
|
||||
onCreateColumn: (column: Omit<Column, 'id' | 'board_id' | 'created_at' | 'updated_at'>) => void;
|
||||
onUpdateColumn: (columnId: string, updates: Partial<Column>) => void;
|
||||
onDeleteColumn: (columnId: string) => Promise<unknown>;
|
||||
onReorderColumns: (columns: Column[]) => void;
|
||||
onSaveBoard: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
const COLOR_OPTIONS = [
|
||||
{ value: 'bg-neutral-400', label: 'Gray' },
|
||||
{ value: 'bg-red-500', label: 'Red' },
|
||||
{ value: 'bg-orange-500', label: 'Orange' },
|
||||
{ value: 'bg-yellow-500', label: 'Yellow' },
|
||||
{ value: 'bg-green-500', label: 'Green' },
|
||||
{ value: 'bg-blue-500', label: 'Blue' },
|
||||
{ value: 'bg-indigo-500', label: 'Indigo' },
|
||||
{ value: 'bg-violet-500', label: 'Violet' },
|
||||
{ value: 'bg-cyan-500', label: 'Cyan' },
|
||||
{ value: 'bg-sky-500', label: 'Sky' },
|
||||
];
|
||||
|
||||
function SortableColumnRow({
|
||||
column,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: {
|
||||
column: Column;
|
||||
onUpdate: (updates: Partial<Column>) => void;
|
||||
onDelete: () => Promise<unknown>;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
|
||||
id: column.id,
|
||||
});
|
||||
const { toast } = useToast();
|
||||
const deleteRequest = useRequest(onDelete);
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
onUpdate({ name: value });
|
||||
};
|
||||
|
||||
const handleColorChange = (value: string) => {
|
||||
onUpdate({ color: value });
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteRequest.run();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to delete column',
|
||||
description: error instanceof Error ? error.message : 'Request failed',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="flex gap-2 items-start">
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 p-1 rounded-md text-muted-foreground hover:text-foreground cursor-grab"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<IconGripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex-1 grid grid-cols-[1fr_1fr_auto] gap-2 items-start">
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
placeholder="Column title"
|
||||
value={column.name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<select
|
||||
value={column.color}
|
||||
onChange={(e) => handleColorChange(e.target.value)}
|
||||
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm"
|
||||
>
|
||||
{COLOR_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteRequest.isLoading}
|
||||
className="mt-1"
|
||||
>
|
||||
<IconTrash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BoardCard({
|
||||
board,
|
||||
columns,
|
||||
isBoardDirty,
|
||||
areColumnsDirty,
|
||||
onUpdateBoard,
|
||||
onDeleteBoard,
|
||||
onCreateColumn,
|
||||
onUpdateColumn,
|
||||
onDeleteColumn,
|
||||
onReorderColumns,
|
||||
onSaveBoard,
|
||||
}: BoardCardProps) {
|
||||
const columnItems = useMemo(() => columns.map((column) => column.id), [columns]);
|
||||
const { toast } = useToast();
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const isMounted = useSyncExternalStore(
|
||||
() => () => {},
|
||||
() => true,
|
||||
() => false
|
||||
);
|
||||
|
||||
const saveBoardRequest = useRequest(onSaveBoard);
|
||||
const deleteBoardRequest = useRequest(onDeleteBoard);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = columns.findIndex((column) => column.id === active.id);
|
||||
const newIndex = columns.findIndex((column) => column.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
const nextColumns = arrayMove(columns, oldIndex, newIndex).map((column, index) => ({
|
||||
...column,
|
||||
position: index,
|
||||
}));
|
||||
onReorderColumns(nextColumns);
|
||||
};
|
||||
|
||||
const handleAddColumn = async () => {
|
||||
const lastColor = columns[columns.length - 1]?.color ?? COLOR_OPTIONS[0].value;
|
||||
const lastIndex = COLOR_OPTIONS.findIndex((option) => option.value === lastColor);
|
||||
const nextColor =
|
||||
lastIndex >= 0 ? COLOR_OPTIONS[(lastIndex + 1) % COLOR_OPTIONS.length].value : COLOR_OPTIONS[0].value;
|
||||
onCreateColumn({
|
||||
name: '',
|
||||
position: columns.length,
|
||||
state: 'TODO',
|
||||
color: nextColor,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteBoard = async () => {
|
||||
try {
|
||||
await deleteBoardRequest.run();
|
||||
setDeleteOpen(false);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to delete board',
|
||||
description: error instanceof Error ? error.message : 'Request failed',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveBoard = async () => {
|
||||
try {
|
||||
await saveBoardRequest.run();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to save board changes',
|
||||
description: error instanceof Error ? error.message : 'Request failed',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-2 flex-1">
|
||||
<Label className="flex items-center gap-2">
|
||||
<span>Board Name</span>
|
||||
{isBoardDirty && <UnsavedChangesBadge />}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={board.name}
|
||||
onChange={(e) => onUpdateBoard({ name: e.target.value })}
|
||||
/>
|
||||
<UnsavedSaveButton
|
||||
isDirty={isBoardDirty || areColumnsDirty}
|
||||
isLoading={saveBoardRequest.isLoading}
|
||||
status={saveBoardRequest.status}
|
||||
onClick={handleSaveBoard}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<span>Columns</span>
|
||||
{areColumnsDirty && <UnsavedChangesBadge />}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddColumn}
|
||||
>
|
||||
<IconPlus className="h-4 w-4 mr-1" />
|
||||
Add Column
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{isMounted ? (
|
||||
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={columnItems}>
|
||||
{columns.map((column) => (
|
||||
<SortableColumnRow
|
||||
key={column.id}
|
||||
column={column}
|
||||
onUpdate={(updates) => onUpdateColumn(column.id, updates)}
|
||||
onDelete={() => onDeleteColumn(column.id)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : (
|
||||
columns.map((column) => (
|
||||
<SortableColumnRow
|
||||
key={column.id}
|
||||
column={column}
|
||||
onUpdate={(updates) => onUpdateColumn(column.id, updates)}
|
||||
onDelete={() => onDeleteColumn(column.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
disabled={deleteBoardRequest.isLoading}
|
||||
>
|
||||
<IconTrash className="h-4 w-4 mr-2" />
|
||||
Delete Board
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete board</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will remove the board and its columns. Tasks will not be deleted. This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setDeleteOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" variant="destructive" onClick={handleDeleteBoard}>
|
||||
Delete Board
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { IconFolder, IconTrash, IconEdit, IconCheck, IconX, IconPlus } from '@tabler/icons-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { Context } from '@/lib/settings/types';
|
||||
|
||||
type ContextCardProps = {
|
||||
context: Context;
|
||||
onUpdate: (context: Context) => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
const COLOR_OPTIONS = [
|
||||
{ value: 'bg-neutral-400', label: 'Gray' },
|
||||
{ value: 'bg-red-500', label: 'Red' },
|
||||
{ value: 'bg-orange-500', label: 'Orange' },
|
||||
{ value: 'bg-yellow-500', label: 'Yellow' },
|
||||
{ value: 'bg-green-500', label: 'Green' },
|
||||
{ value: 'bg-blue-500', label: 'Blue' },
|
||||
{ value: 'bg-indigo-500', label: 'Indigo' },
|
||||
{ value: 'bg-violet-500', label: 'Violet' },
|
||||
{ value: 'bg-cyan-500', label: 'Cyan' },
|
||||
{ value: 'bg-sky-500', label: 'Sky' },
|
||||
];
|
||||
|
||||
export function ContextCard({ context, onUpdate, onDelete }: ContextCardProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editedContext, setEditedContext] = useState<Context>(context);
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdate(editedContext);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditedContext(context);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleAddColumn = () => {
|
||||
setEditedContext({
|
||||
...editedContext,
|
||||
columns: [
|
||||
...editedContext.columns,
|
||||
{ id: crypto.randomUUID(), title: '', color: 'bg-neutral-400' },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveColumn = (id: string) => {
|
||||
setEditedContext({
|
||||
...editedContext,
|
||||
columns: editedContext.columns.filter((c) => c.id !== id),
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateColumn = (id: string, field: 'title' | 'color' | 'id', value: string) => {
|
||||
setEditedContext({
|
||||
...editedContext,
|
||||
columns: editedContext.columns.map((c) =>
|
||||
c.id === id ? { ...c, [field]: value } : c
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Context Name</Label>
|
||||
<Input
|
||||
value={editedContext.name}
|
||||
onChange={(e) => setEditedContext({ ...editedContext, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Kanban Columns</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddColumn}>
|
||||
<IconPlus className="h-4 w-4 mr-1" />
|
||||
Add Column
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{editedContext.columns.map((column) => (
|
||||
<div key={column.id} className="flex gap-2 items-start">
|
||||
<div className="flex-1 grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
placeholder="Column title"
|
||||
value={column.title}
|
||||
onChange={(e) =>
|
||||
handleUpdateColumn(column.id, 'title', e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<select
|
||||
value={column.color}
|
||||
onChange={(e) =>
|
||||
handleUpdateColumn(column.id, 'color', e.target.value)
|
||||
}
|
||||
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm"
|
||||
>
|
||||
{COLOR_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Input
|
||||
placeholder="Column ID (e.g., todo, in-progress)"
|
||||
value={column.id}
|
||||
onChange={(e) =>
|
||||
handleUpdateColumn(column.id, 'id', e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveColumn(column.id)}
|
||||
>
|
||||
<IconX className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
<IconX className="h-4 w-4 mr-2" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
<IconCheck className="h-4 w-4 mr-2" />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="hover:bg-accent transition-colors cursor-pointer" onClick={() => setIsEditing(true)}>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
<div className="p-2 bg-muted rounded-md">
|
||||
<IconFolder className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium">{context.name}</h4>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{context.columns.map((column) => {
|
||||
// Map Tailwind bg colors to actual hex values
|
||||
const colorMap: Record<string, string> = {
|
||||
'bg-neutral-400': '#a3a3a3',
|
||||
'bg-red-500': '#ef4444',
|
||||
'bg-orange-500': '#f97316',
|
||||
'bg-yellow-500': '#eab308',
|
||||
'bg-green-500': '#22c55e',
|
||||
'bg-blue-500': '#3b82f6',
|
||||
'bg-indigo-500': '#6366f1',
|
||||
'bg-violet-500': '#8b5cf6',
|
||||
'bg-cyan-500': '#06b6d4',
|
||||
'bg-sky-500': '#0ea5e9',
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge
|
||||
key={column.id}
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
style={{
|
||||
borderLeftWidth: '3px',
|
||||
borderLeftColor: colorMap[column.color] || '#a3a3a3',
|
||||
}}
|
||||
>
|
||||
{column.title}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="icon" onClick={() => setIsEditing(true)}>
|
||||
<IconEdit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={onDelete}>
|
||||
<IconTrash className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -29,10 +29,11 @@ import {
|
||||
} from '@/components/ui/sidebar';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
|
||||
export function SettingsAppSidebar() {
|
||||
const pathname = usePathname();
|
||||
const [workspaces] = React.useState(SETTINGS_DATA.workspaces);
|
||||
const workspaces = useAppStore((state) => state.workspaces.items);
|
||||
const [environments] = React.useState(SETTINGS_DATA.environments);
|
||||
const [agents] = React.useState(SETTINGS_DATA.agents);
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { IconArrowLeft } from '@tabler/icons-react';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { SettingsAppSidebar } from '@/components/settings/settings-app-sidebar';
|
||||
|
||||
export function SettingsLayoutClient({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<SettingsAppSidebar />
|
||||
<SidebarInset>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/" className="flex items-center gap-2">
|
||||
<IconArrowLeft className="h-4 w-4" />
|
||||
Home
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
|
||||
{children}
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { IconAlertCircle } from '@tabler/icons-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RequestIndicator } from '@/components/request-indicator';
|
||||
|
||||
type UnsavedSaveButtonProps = {
|
||||
isDirty: boolean;
|
||||
isLoading: boolean;
|
||||
status: 'idle' | 'loading' | 'success' | 'error';
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const warningButtonClass = 'border-yellow-500/60 text-yellow-500 hover:bg-yellow-500/10';
|
||||
|
||||
export function UnsavedChangesBadge() {
|
||||
return <span className="text-xs text-yellow-500">Unsaved changes</span>;
|
||||
}
|
||||
|
||||
export function UnsavedSaveButton({ isDirty, isLoading, status, onClick }: UnsavedSaveButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={isDirty ? 'secondary' : 'outline'}
|
||||
onClick={onClick}
|
||||
disabled={isLoading}
|
||||
className={isDirty ? warningButtonClass : undefined}
|
||||
>
|
||||
{isDirty && <IconAlertCircle className="h-4 w-4 mr-2" />}
|
||||
Save
|
||||
<span className="ml-2">
|
||||
<RequestIndicator status={status} />
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type ToastVariant = 'default' | 'error';
|
||||
|
||||
type Toast = {
|
||||
id: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
variant?: ToastVariant;
|
||||
};
|
||||
|
||||
type ToastInput = Omit<Toast, 'id'> & { duration?: number };
|
||||
|
||||
type ToastContextValue = {
|
||||
toast: (input: ToastInput) => void;
|
||||
};
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
const toast = useCallback(
|
||||
(input: ToastInput) => {
|
||||
const id = crypto.randomUUID();
|
||||
const nextToast: Toast = {
|
||||
id,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
variant: input.variant ?? 'default',
|
||||
};
|
||||
setToasts((prev) => [nextToast, ...prev]);
|
||||
const duration = input.duration ?? 3000;
|
||||
window.setTimeout(() => removeToast(id), duration);
|
||||
},
|
||||
[removeToast]
|
||||
);
|
||||
|
||||
const value = useMemo(() => ({ toast }), [toast]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div className="fixed right-4 top-4 z-50 flex w-[320px] flex-col gap-2">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={cn(
|
||||
'rounded-md border border-border/60 bg-background px-3 py-2 shadow-md',
|
||||
toast.variant === 'error' && 'border-destructive/50 bg-destructive/10'
|
||||
)}
|
||||
>
|
||||
{toast.title && <div className="text-sm font-medium">{toast.title}</div>}
|
||||
{toast.description && (
|
||||
<div className="text-xs text-muted-foreground">{toast.description}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within ToastProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export type RequestStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||
|
||||
export type RequestState<T> = {
|
||||
status: RequestStatus;
|
||||
data: T | null;
|
||||
error: Error | null;
|
||||
};
|
||||
|
||||
type UseRequestOptions = {
|
||||
successDuration?: number;
|
||||
};
|
||||
|
||||
export function useRequest<TArgs extends unknown[], TData>(
|
||||
fn: (...args: TArgs) => Promise<TData>,
|
||||
options: UseRequestOptions = {}
|
||||
) {
|
||||
const { successDuration = 1500 } = options;
|
||||
const [state, setState] = useState<RequestState<TData>>({
|
||||
status: 'idle',
|
||||
data: null,
|
||||
error: null,
|
||||
});
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const run = useCallback(
|
||||
async (...args: TArgs) => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
setState({ status: 'loading', data: null, error: null });
|
||||
try {
|
||||
const data = await fn(...args);
|
||||
setState({ status: 'success', data, error: null });
|
||||
if (successDuration > 0) {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setState((prev) => ({ ...prev, status: 'idle' }));
|
||||
}, successDuration);
|
||||
}
|
||||
return data;
|
||||
} catch (error) {
|
||||
const normalized = error instanceof Error ? error : new Error('Request failed');
|
||||
setState({ status: 'error', data: null, error: normalized });
|
||||
throw normalized;
|
||||
}
|
||||
},
|
||||
[fn, successDuration]
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
isLoading: state.status === 'loading',
|
||||
run,
|
||||
reset: () => setState({ status: 'idle', data: null, error: null }),
|
||||
};
|
||||
}
|
||||
@@ -13,7 +13,12 @@ export function snapshotToState(snapshot: BoardSnapshot): Partial<AppState> {
|
||||
return {
|
||||
kanban: {
|
||||
boardId: snapshot.board.id,
|
||||
columns: snapshot.columns.map((column) => ({ id: column.id, title: column.name })),
|
||||
columns: snapshot.columns.map((column) => ({
|
||||
id: column.id,
|
||||
title: column.name,
|
||||
color: column.color ?? 'bg-neutral-400',
|
||||
position: column.position,
|
||||
})),
|
||||
tasks,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { immer } from 'zustand/middleware/immer';
|
||||
|
||||
export type KanbanState = {
|
||||
boardId: string | null;
|
||||
columns: Array<{ id: string; title: string }>;
|
||||
columns: Array<{ id: string; title: string; color: string; position: number }>;
|
||||
tasks: Array<{ id: string; columnId: string; title: string }>;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,11 @@ export type WorkspaceState = {
|
||||
activeId: string | null;
|
||||
};
|
||||
|
||||
export type BoardState = {
|
||||
items: Array<{ id: string; workspaceId: string; name: string }>;
|
||||
activeId: string | null;
|
||||
};
|
||||
|
||||
export type TaskState = {
|
||||
activeTaskId: string | null;
|
||||
};
|
||||
@@ -37,6 +42,7 @@ export type ConnectionState = {
|
||||
export type AppState = {
|
||||
kanban: KanbanState;
|
||||
workspaces: WorkspaceState;
|
||||
boards: BoardState;
|
||||
tasks: TaskState;
|
||||
agents: AgentState;
|
||||
terminal: TerminalState;
|
||||
@@ -45,6 +51,8 @@ export type AppState = {
|
||||
hydrate: (state: Partial<AppState>) => void;
|
||||
setActiveWorkspace: (workspaceId: string | null) => void;
|
||||
setWorkspaces: (workspaces: WorkspaceState['items']) => void;
|
||||
setActiveBoard: (boardId: string | null) => void;
|
||||
setBoards: (boards: BoardState['items']) => void;
|
||||
setTerminalOutput: (terminalId: string, data: string) => void;
|
||||
setConnectionStatus: (status: ConnectionState['status'], error?: string | null) => void;
|
||||
};
|
||||
@@ -54,6 +62,7 @@ export type AppStore = ReturnType<typeof createAppStore>;
|
||||
const defaultState: AppState = {
|
||||
kanban: { boardId: null, columns: [], tasks: [] },
|
||||
workspaces: { items: [], activeId: null },
|
||||
boards: { items: [], activeId: null },
|
||||
tasks: { activeTaskId: null },
|
||||
agents: { agents: [] },
|
||||
terminal: { terminals: [] },
|
||||
@@ -62,6 +71,8 @@ const defaultState: AppState = {
|
||||
hydrate: () => undefined,
|
||||
setActiveWorkspace: () => undefined,
|
||||
setWorkspaces: () => undefined,
|
||||
setActiveBoard: () => undefined,
|
||||
setBoards: () => undefined,
|
||||
setTerminalOutput: () => undefined,
|
||||
setConnectionStatus: () => undefined,
|
||||
};
|
||||
@@ -86,6 +97,17 @@ export function createAppStore(initialState?: Partial<AppState>) {
|
||||
draft.workspaces.activeId = workspaces[0].id;
|
||||
}
|
||||
}),
|
||||
setBoards: (boards) =>
|
||||
set((draft) => {
|
||||
draft.boards.items = boards;
|
||||
if (!draft.boards.activeId && boards.length) {
|
||||
draft.boards.activeId = boards[0].id;
|
||||
}
|
||||
}),
|
||||
setActiveBoard: (boardId) =>
|
||||
set((draft) => {
|
||||
draft.boards.activeId = boardId;
|
||||
}),
|
||||
setTerminalOutput: (terminalId, data) =>
|
||||
set((draft) => {
|
||||
const existing = draft.terminal.terminals.find((terminal) => terminal.id === terminalId);
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
export type BackendMessageType =
|
||||
| 'kanban.update'
|
||||
| 'task.update'
|
||||
| 'agent.update'
|
||||
| 'task.updated'
|
||||
| 'agent.updated'
|
||||
| 'terminal.output'
|
||||
| 'diff.update'
|
||||
| 'system.error';
|
||||
| 'system.error'
|
||||
| 'workspace.created'
|
||||
| 'workspace.updated'
|
||||
| 'workspace.deleted'
|
||||
| 'board.created'
|
||||
| 'board.updated'
|
||||
| 'board.deleted'
|
||||
| 'column.created'
|
||||
| 'column.updated'
|
||||
| 'column.deleted';
|
||||
|
||||
export type BackendMessage<T extends BackendMessageType, P> = {
|
||||
type: T;
|
||||
id?: string;
|
||||
type: 'request' | 'response' | 'notification' | 'error';
|
||||
action: T;
|
||||
payload: P;
|
||||
requestId?: string;
|
||||
timestamp?: string;
|
||||
};
|
||||
|
||||
export type KanbanUpdatePayload = {
|
||||
boardId: string;
|
||||
columns: Array<{ id: string; title: string }>;
|
||||
columns: Array<{ id: string; title: string; color?: string; position?: number }>;
|
||||
tasks: Array<{ id: string; columnId: string; title: string }>;
|
||||
};
|
||||
|
||||
@@ -53,11 +63,49 @@ export type SystemErrorPayload = {
|
||||
code?: string;
|
||||
};
|
||||
|
||||
export type WorkspacePayload = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
owner_id?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type BoardPayload = {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type ColumnPayload = {
|
||||
id: string;
|
||||
board_id: string;
|
||||
name: string;
|
||||
position: number;
|
||||
state: string;
|
||||
color: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type BackendMessageMap = {
|
||||
'kanban.update': BackendMessage<'kanban.update', KanbanUpdatePayload>;
|
||||
'task.update': BackendMessage<'task.update', TaskUpdatePayload>;
|
||||
'agent.update': BackendMessage<'agent.update', AgentUpdatePayload>;
|
||||
'task.updated': BackendMessage<'task.updated', TaskUpdatePayload>;
|
||||
'agent.updated': BackendMessage<'agent.updated', AgentUpdatePayload>;
|
||||
'terminal.output': BackendMessage<'terminal.output', TerminalOutputPayload>;
|
||||
'diff.update': BackendMessage<'diff.update', DiffUpdatePayload>;
|
||||
'system.error': BackendMessage<'system.error', SystemErrorPayload>;
|
||||
'workspace.created': BackendMessage<'workspace.created', WorkspacePayload>;
|
||||
'workspace.updated': BackendMessage<'workspace.updated', WorkspacePayload>;
|
||||
'workspace.deleted': BackendMessage<'workspace.deleted', WorkspacePayload>;
|
||||
'board.created': BackendMessage<'board.created', BoardPayload>;
|
||||
'board.updated': BackendMessage<'board.updated', BoardPayload>;
|
||||
'board.deleted': BackendMessage<'board.deleted', BoardPayload>;
|
||||
'column.created': BackendMessage<'column.created', ColumnPayload>;
|
||||
'column.updated': BackendMessage<'column.updated', ColumnPayload>;
|
||||
'column.deleted': BackendMessage<'column.deleted', ColumnPayload>;
|
||||
};
|
||||
|
||||
@@ -31,6 +31,7 @@ export type Column = {
|
||||
name: string;
|
||||
position: number;
|
||||
state: TaskState;
|
||||
color: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
@@ -65,6 +66,11 @@ export type ListBoardsResponse = {
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type ListColumnsResponse = {
|
||||
columns: Column[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type ListWorkspacesResponse = {
|
||||
workspaces: Workspace[];
|
||||
total: number;
|
||||
|
||||
@@ -8,6 +8,10 @@ export class WebSocketClient {
|
||||
private socket: WebSocket | null = null;
|
||||
private status: WebSocketStatus = 'idle';
|
||||
private handlers = new Map<BackendMessageType, Set<MessageHandler<BackendMessageType>>>();
|
||||
private pendingRequests = new Map<
|
||||
string,
|
||||
{ resolve: (payload: unknown) => void; reject: (error: Error) => void; timeout: ReturnType<typeof setTimeout> }
|
||||
>();
|
||||
private pendingQueue: string[] = [];
|
||||
|
||||
constructor(
|
||||
@@ -32,7 +36,35 @@ export class WebSocketClient {
|
||||
this.socket.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data) as BackendMessageMap[BackendMessageType];
|
||||
const handlers = this.handlers.get(message.type);
|
||||
if (message.type === 'response' && message.id) {
|
||||
const pending = this.pendingRequests.get(message.id);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout);
|
||||
this.pendingRequests.delete(message.id);
|
||||
pending.resolve(message.payload);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === 'error' && message.id) {
|
||||
const pending = this.pendingRequests.get(message.id);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout);
|
||||
this.pendingRequests.delete(message.id);
|
||||
const errorMessage =
|
||||
typeof message.payload === 'object' && message.payload && 'message' in message.payload
|
||||
? String((message.payload as { message?: string }).message)
|
||||
: 'WebSocket request failed';
|
||||
pending.reject(new Error(errorMessage));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type !== 'notification') {
|
||||
return;
|
||||
}
|
||||
const action = message?.action;
|
||||
if (!action) return;
|
||||
const handlers = this.handlers.get(action);
|
||||
if (!handlers) return;
|
||||
handlers.forEach((handler) => handler(message));
|
||||
} catch {
|
||||
@@ -66,6 +98,18 @@ export class WebSocketClient {
|
||||
this.socket.send(data);
|
||||
}
|
||||
|
||||
request<T>(action: string, payload: unknown, timeoutMs = 5000): Promise<T> {
|
||||
const id = crypto.randomUUID();
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new Error(`WebSocket request timed out: ${action}`));
|
||||
}, timeoutMs);
|
||||
this.pendingRequests.set(id, { resolve, reject, timeout });
|
||||
this.send({ id, type: 'request', action, payload });
|
||||
});
|
||||
}
|
||||
|
||||
on<T extends BackendMessageType>(type: T, handler: MessageHandler<T>) {
|
||||
const handlers = this.handlers.get(type) ?? new Set();
|
||||
handlers.add(handler as MessageHandler<BackendMessageType>);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { WebSocketClient } from '@/lib/ws/client';
|
||||
|
||||
let activeClient: WebSocketClient | null = null;
|
||||
|
||||
export function setWebSocketClient(client: WebSocketClient | null) {
|
||||
activeClient = client;
|
||||
}
|
||||
|
||||
export function getWebSocketClient() {
|
||||
return activeClient;
|
||||
}
|
||||
+182
-3
@@ -9,12 +9,17 @@ export function registerWsHandlers(store: StoreApi<AppState>) {
|
||||
...state,
|
||||
kanban: {
|
||||
boardId: message.payload.boardId,
|
||||
columns: message.payload.columns,
|
||||
columns: message.payload.columns.map((column, index) => ({
|
||||
id: column.id,
|
||||
title: column.title,
|
||||
color: column.color ?? 'bg-neutral-400',
|
||||
position: column.position ?? index,
|
||||
})),
|
||||
tasks: message.payload.tasks,
|
||||
},
|
||||
}));
|
||||
},
|
||||
'task.update': (message: BackendMessageMap['task.update']) => {
|
||||
'task.updated': (message: BackendMessageMap['task.updated']) => {
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
tasks: {
|
||||
@@ -23,7 +28,7 @@ export function registerWsHandlers(store: StoreApi<AppState>) {
|
||||
},
|
||||
}));
|
||||
},
|
||||
'agent.update': (message: BackendMessageMap['agent.update']) => {
|
||||
'agent.updated': (message: BackendMessageMap['agent.updated']) => {
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
agents: {
|
||||
@@ -48,6 +53,180 @@ export function registerWsHandlers(store: StoreApi<AppState>) {
|
||||
},
|
||||
}));
|
||||
},
|
||||
'workspace.created': (message: BackendMessageMap['workspace.created']) => {
|
||||
store.setState((state) => {
|
||||
const exists = state.workspaces.items.some((item) => item.id === message.payload.id);
|
||||
const items = exists
|
||||
? state.workspaces.items.map((item) =>
|
||||
item.id === message.payload.id ? { ...item, name: message.payload.name } : item
|
||||
)
|
||||
: [{ id: message.payload.id, name: message.payload.name }, ...state.workspaces.items];
|
||||
const activeId = state.workspaces.activeId ?? message.payload.id;
|
||||
return {
|
||||
...state,
|
||||
workspaces: {
|
||||
items,
|
||||
activeId,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
'workspace.updated': (message: BackendMessageMap['workspace.updated']) => {
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
workspaces: {
|
||||
...state.workspaces,
|
||||
items: state.workspaces.items.map((item) =>
|
||||
item.id === message.payload.id ? { ...item, name: message.payload.name } : item
|
||||
),
|
||||
},
|
||||
}));
|
||||
},
|
||||
'workspace.deleted': (message: BackendMessageMap['workspace.deleted']) => {
|
||||
store.setState((state) => {
|
||||
const items = state.workspaces.items.filter((item) => item.id !== message.payload.id);
|
||||
const activeId =
|
||||
state.workspaces.activeId === message.payload.id ? items[0]?.id ?? null : state.workspaces.activeId;
|
||||
const clearBoards = state.workspaces.activeId === message.payload.id;
|
||||
return {
|
||||
...state,
|
||||
workspaces: {
|
||||
items,
|
||||
activeId,
|
||||
},
|
||||
boards: clearBoards ? { items: [], activeId: null } : state.boards,
|
||||
kanban: clearBoards ? { boardId: null, columns: [], tasks: [] } : state.kanban,
|
||||
};
|
||||
});
|
||||
},
|
||||
'board.created': (message: BackendMessageMap['board.created']) => {
|
||||
store.setState((state) => {
|
||||
if (state.workspaces.activeId !== message.payload.workspace_id) {
|
||||
return state;
|
||||
}
|
||||
const exists = state.boards.items.some((item) => item.id === message.payload.id);
|
||||
if (exists) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
boards: {
|
||||
items: [
|
||||
{
|
||||
id: message.payload.id,
|
||||
workspaceId: message.payload.workspace_id,
|
||||
name: message.payload.name,
|
||||
},
|
||||
...state.boards.items,
|
||||
],
|
||||
activeId: state.boards.activeId ?? message.payload.id,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
'board.updated': (message: BackendMessageMap['board.updated']) => {
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
boards: {
|
||||
...state.boards,
|
||||
items: state.boards.items.map((item) =>
|
||||
item.id === message.payload.id ? { ...item, name: message.payload.name } : item
|
||||
),
|
||||
},
|
||||
}));
|
||||
},
|
||||
'board.deleted': (message: BackendMessageMap['board.deleted']) => {
|
||||
store.setState((state) => {
|
||||
const items = state.boards.items.filter((item) => item.id !== message.payload.id);
|
||||
const nextActiveId =
|
||||
state.boards.activeId === message.payload.id ? items[0]?.id ?? null : state.boards.activeId;
|
||||
return {
|
||||
...state,
|
||||
boards: {
|
||||
items,
|
||||
activeId: nextActiveId,
|
||||
},
|
||||
kanban:
|
||||
state.kanban.boardId === message.payload.id
|
||||
? { boardId: nextActiveId, columns: [], tasks: [] }
|
||||
: state.kanban,
|
||||
};
|
||||
});
|
||||
},
|
||||
'column.created': (message: BackendMessageMap['column.created']) => {
|
||||
store.setState((state) => {
|
||||
if (state.kanban.boardId !== message.payload.board_id) {
|
||||
return state;
|
||||
}
|
||||
const nextColumns = [
|
||||
...state.kanban.columns,
|
||||
{
|
||||
id: message.payload.id,
|
||||
title: message.payload.name,
|
||||
color: message.payload.color,
|
||||
position: message.payload.position,
|
||||
},
|
||||
].sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
||||
return {
|
||||
...state,
|
||||
kanban: {
|
||||
...state.kanban,
|
||||
columns: nextColumns,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
'column.updated': (message: BackendMessageMap['column.updated']) => {
|
||||
store.setState((state) => {
|
||||
if (state.kanban.boardId !== message.payload.board_id) {
|
||||
return state;
|
||||
}
|
||||
const exists = state.kanban.columns.some((column) => column.id === message.payload.id);
|
||||
const updatedColumns = exists
|
||||
? state.kanban.columns.map((column) =>
|
||||
column.id === message.payload.id
|
||||
? {
|
||||
...column,
|
||||
title: message.payload.name,
|
||||
color: message.payload.color,
|
||||
position: message.payload.position,
|
||||
}
|
||||
: column
|
||||
)
|
||||
: [
|
||||
...state.kanban.columns,
|
||||
{
|
||||
id: message.payload.id,
|
||||
title: message.payload.name,
|
||||
color: message.payload.color,
|
||||
position: message.payload.position,
|
||||
},
|
||||
];
|
||||
const nextColumns = updatedColumns.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
||||
return {
|
||||
...state,
|
||||
kanban: {
|
||||
...state.kanban,
|
||||
columns: nextColumns,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
'column.deleted': (message: BackendMessageMap['column.deleted']) => {
|
||||
store.setState((state) => {
|
||||
if (state.kanban.boardId !== message.payload.board_id) {
|
||||
return state;
|
||||
}
|
||||
const nextColumns = state.kanban.columns.filter((column) => column.id !== message.payload.id);
|
||||
return {
|
||||
...state,
|
||||
kanban: {
|
||||
...state.kanban,
|
||||
columns: nextColumns,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
'system.error': () => {
|
||||
// TODO: surface as toast/notification once UI is ready.
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { StoreApi } from 'zustand';
|
||||
import { WebSocketClient } from '@/lib/ws/client';
|
||||
import { registerWsHandlers } from '@/lib/ws/router';
|
||||
import type { AppState } from '@/lib/state/store';
|
||||
import { setWebSocketClient } from '@/lib/ws/connection';
|
||||
|
||||
export function useWebSocket(store: StoreApi<AppState>, url: string) {
|
||||
const clientRef = useRef<WebSocketClient | null>(null);
|
||||
@@ -31,6 +32,7 @@ export function useWebSocket(store: StoreApi<AppState>, url: string) {
|
||||
});
|
||||
clientRef.current = client;
|
||||
client.connect();
|
||||
setWebSocketClient(client);
|
||||
|
||||
const handlers = registerWsHandlers(store);
|
||||
const unsubscribers = Object.entries(handlers).map(([type, handler]) =>
|
||||
@@ -40,6 +42,7 @@ export function useWebSocket(store: StoreApi<AppState>, url: string) {
|
||||
return () => {
|
||||
unsubscribers.forEach((unsubscribe) => unsubscribe());
|
||||
client.disconnect();
|
||||
setWebSocketClient(null);
|
||||
};
|
||||
}, [store, url]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user