Compare commits

...

3 Commits

Author SHA1 Message Date
hyung-hwan fa44e0a762 implemented user avatar
multiple assignees to a board card.
attachments to a board card
2026-06-17 21:43:27 +09:00
hyung-hwan b104dcf340 added description icon and tooltip in a card display 2026-06-17 09:01:21 +09:00
hyung-hwan 805ac6331b added checklist to a card 2026-06-17 08:00:35 +09:00
22 changed files with 2718 additions and 114 deletions
+555 -43
View File
@@ -2,12 +2,15 @@ package db
import "database/sql" import "database/sql"
import "errors" import "errors"
import "strings"
import "time" import "time"
import "codit/internal/models" import "codit/internal/models"
import "codit/internal/util" import "codit/internal/util"
var ErrCommentNotFound = errors.New("comment not found") var ErrCommentNotFound = errors.New("comment not found")
var ErrChecklistItemNotFound = errors.New("checklist item not found")
var ErrBlockAttachmentNotFound = errors.New("block attachment not found")
const blockCommentSelectCols = ` const blockCommentSelectCols = `
c.public_id, bl.public_id, c.public_id, bl.public_id,
@@ -136,39 +139,409 @@ func (s *Store) DeleteBlockComment(boardID, blockID, commentID string) error {
return nil return nil
} }
const blockChecklistItemSelectCols = `
ci.public_id, bl.public_id,
ci.title, ci.done, ci.display_order,
COALESCE(cu.public_id, ''), COALESCE(uu.public_id, ''),
ci.created_at, ci.updated_at, ci.delete_at`
const blockChecklistItemSelectFrom = `
FROM block_checklist_items ci
JOIN blocks bl ON bl.id = ci.block_id
LEFT JOIN users cu ON cu.id = ci.created_by
LEFT JOIN users uu ON uu.id = ci.updated_by`
func scanBlockChecklistItem(row interface{ Scan(...any) error }, item *models.BlockChecklistItem) error {
return row.Scan(
&item.ID,
&item.BlockID,
&item.Title,
&item.Done,
&item.DisplayOrder,
&item.CreatedBy, &item.UpdatedBy,
&item.CreatedAt, &item.UpdatedAt, &item.DeleteAt,
)
}
func (s *Store) ListBlockChecklistItems(boardID, blockID string) ([]models.BlockChecklistItem, error) {
var rows *sql.Rows
var err error
var items []models.BlockChecklistItem
var item models.BlockChecklistItem
rows, err = s.Query(
`SELECT`+blockChecklistItemSelectCols+blockChecklistItemSelectFrom+`
JOIN boards brd ON brd.id = bl.board_id
WHERE brd.public_id = ? AND bl.public_id = ? AND ci.delete_at = 0
ORDER BY ci.display_order ASC, ci.created_at ASC`, boardID, blockID)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
item = models.BlockChecklistItem{}
err = scanBlockChecklistItem(rows, &item)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Store) CreateBlockChecklistItem(boardID, blockID, title, userID string) (models.BlockChecklistItem, error) {
var id string
var err error
var now int64
var internalBlockID int64
var internalUserID int64
var maxOrder int64
var item models.BlockChecklistItem
id, err = util.NewID()
if err != nil {
return item, err
}
now = time.Now().Unix()
err = s.QueryRow(`
SELECT bl.id FROM blocks bl
JOIN boards brd ON brd.id = bl.board_id
WHERE brd.public_id = ? AND bl.public_id = ? AND bl.delete_at = 0`,
boardID, blockID).Scan(&internalBlockID)
if err == sql.ErrNoRows {
return item, sql.ErrNoRows
}
if err != nil {
return item, err
}
err = s.QueryRow(`SELECT id FROM users WHERE public_id = ?`, userID).Scan(&internalUserID)
if err != nil {
return item, err
}
err = s.QueryRow(`SELECT COALESCE(MAX(display_order), -1) FROM block_checklist_items WHERE block_id = ? AND delete_at = 0`, internalBlockID).Scan(&maxOrder)
if err != nil {
return item, err
}
_, err = s.Exec(`
INSERT INTO block_checklist_items (public_id, block_id, title, done, display_order, created_by, updated_by, created_at, updated_at)
VALUES (?, ?, ?, 0, ?, ?, ?, ?, ?)`,
id, internalBlockID, title, maxOrder+1, internalUserID, internalUserID, now, now)
if err != nil {
return item, err
}
err = scanBlockChecklistItem(
s.QueryRow(`SELECT`+blockChecklistItemSelectCols+blockChecklistItemSelectFrom+` WHERE ci.public_id = ?`, id),
&item)
return item, err
}
func (s *Store) UpdateBlockChecklistItem(boardID, blockID, itemID, title string, done bool, userID string) (models.BlockChecklistItem, error) {
var now int64
var result sql.Result
var n int64
var err error
var item models.BlockChecklistItem
now = time.Now().Unix()
result, err = s.Exec(`
UPDATE block_checklist_items SET title = ?, done = ?, updated_by = (SELECT id FROM users WHERE public_id = ?), updated_at = ?
WHERE public_id = ?
AND delete_at = 0
AND block_id = (
SELECT bl.id FROM blocks bl
JOIN boards brd ON brd.id = bl.board_id
WHERE brd.public_id = ? AND bl.public_id = ? AND bl.delete_at = 0
)`, title, done, userID, now, itemID, boardID, blockID)
if err != nil {
return item, err
}
n, err = result.RowsAffected()
if err != nil {
return item, err
}
if n == 0 {
return item, ErrChecklistItemNotFound
}
err = scanBlockChecklistItem(
s.QueryRow(`SELECT`+blockChecklistItemSelectCols+blockChecklistItemSelectFrom+` WHERE ci.public_id = ?`, itemID),
&item)
return item, err
}
func (s *Store) DeleteBlockChecklistItem(boardID, blockID, itemID string) error {
var now int64
var result sql.Result
var n int64
var err error
now = time.Now().Unix()
result, err = s.Exec(`
UPDATE block_checklist_items SET delete_at = ?
WHERE public_id = ?
AND delete_at = 0
AND block_id = (
SELECT bl.id FROM blocks bl
JOIN boards brd ON brd.id = bl.board_id
WHERE brd.public_id = ? AND bl.public_id = ? AND bl.delete_at = 0
)`, now, itemID, boardID, blockID)
if err != nil {
return err
}
n, err = result.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrChecklistItemNotFound
}
return nil
}
func (s *Store) ReorderBlockChecklistItems(boardID, blockID string, ids []string) ([]models.BlockChecklistItem, error) {
var internalBlockID int64
var err error
var now int64
var tx *sql.Tx
var owned bool
var i int
var id string
var result sql.Result
var n int64
now = time.Now().Unix()
tx, owned, err = s.begin()
if err != nil {
return nil, err
}
err = tx.QueryRow(`
SELECT bl.id FROM blocks bl
JOIN boards brd ON brd.id = bl.board_id
WHERE brd.public_id = ? AND bl.public_id = ? AND bl.delete_at = 0`,
boardID, blockID).Scan(&internalBlockID)
if err == sql.ErrNoRows {
rollbackIfOwned(tx, owned)
return nil, sql.ErrNoRows
}
if err != nil {
rollbackIfOwned(tx, owned)
return nil, err
}
for i = 0; i < len(ids); i++ {
id = ids[i]
result, err = tx.Exec(`
UPDATE block_checklist_items SET display_order = ?, updated_at = ?
WHERE public_id = ? AND block_id = ? AND delete_at = 0`,
i, now, id, internalBlockID)
if err != nil {
rollbackIfOwned(tx, owned)
return nil, err
}
n, err = result.RowsAffected()
if err != nil {
rollbackIfOwned(tx, owned)
return nil, err
}
if n == 0 {
rollbackIfOwned(tx, owned)
return nil, ErrChecklistItemNotFound
}
}
err = commitIfOwned(tx, owned)
if err != nil {
return nil, err
}
return s.ListBlockChecklistItems(boardID, blockID)
}
const blockAttachmentSelectCols = `
ba.public_id, bl.public_id,
ba.filename, ba.content_type, ba.size, ba.storage_path,
COALESCE(u.public_id, ''),
ba.created_at, ba.delete_at`
const blockAttachmentSelectFrom = `
FROM block_attachments ba
JOIN blocks bl ON bl.id = ba.block_id
LEFT JOIN users u ON u.id = ba.created_by`
func scanBlockAttachment(row interface{ Scan(...any) error }, item *models.BlockAttachment) error {
return row.Scan(
&item.ID,
&item.BlockID,
&item.Filename,
&item.ContentType,
&item.Size,
&item.StoragePath,
&item.CreatedBy,
&item.CreatedAt,
&item.DeleteAt,
)
}
func (s *Store) ListBlockAttachments(boardID, blockID string) ([]models.BlockAttachment, error) {
var rows *sql.Rows
var err error
var items []models.BlockAttachment
var item models.BlockAttachment
rows, err = s.Query(
`SELECT`+blockAttachmentSelectCols+blockAttachmentSelectFrom+`
JOIN boards brd ON brd.id = bl.board_id
WHERE brd.public_id = ? AND bl.public_id = ? AND ba.delete_at = 0
ORDER BY ba.created_at DESC`, boardID, blockID)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
item = models.BlockAttachment{}
err = scanBlockAttachment(rows, &item)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Store) CreateBlockAttachment(boardID, blockID string, item models.BlockAttachment) (models.BlockAttachment, error) {
var err error
var internalBlockID int64
var internalUserID int64
var now int64
now = time.Now().Unix()
err = s.QueryRow(`
SELECT bl.id FROM blocks bl
JOIN boards brd ON brd.id = bl.board_id
WHERE brd.public_id = ? AND bl.public_id = ? AND bl.delete_at = 0`,
boardID, blockID).Scan(&internalBlockID)
if err == sql.ErrNoRows {
return item, sql.ErrNoRows
}
if err != nil {
return item, err
}
err = s.QueryRow(`SELECT id FROM users WHERE public_id = ?`, item.CreatedBy).Scan(&internalUserID)
if err != nil {
return item, err
}
item.BlockID = blockID
item.CreatedAt = now
_, err = s.Exec(`
INSERT INTO block_attachments (public_id, block_id, filename, content_type, size, storage_path, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
item.ID, internalBlockID, item.Filename, item.ContentType, item.Size, item.StoragePath, internalUserID, now)
if err != nil {
return item, err
}
err = scanBlockAttachment(
s.QueryRow(`SELECT`+blockAttachmentSelectCols+blockAttachmentSelectFrom+` WHERE ba.public_id = ?`, item.ID),
&item)
return item, err
}
func (s *Store) GetBlockAttachment(boardID, blockID, attachmentID string) (models.BlockAttachment, error) {
var item models.BlockAttachment
var err error
err = scanBlockAttachment(
s.QueryRow(`SELECT`+blockAttachmentSelectCols+blockAttachmentSelectFrom+`
JOIN boards brd ON brd.id = bl.board_id
WHERE brd.public_id = ? AND bl.public_id = ? AND ba.public_id = ? AND ba.delete_at = 0`,
boardID, blockID, attachmentID),
&item)
if err == sql.ErrNoRows {
return item, ErrBlockAttachmentNotFound
}
return item, err
}
func (s *Store) DeleteBlockAttachment(boardID, blockID, attachmentID string) (models.BlockAttachment, error) {
var item models.BlockAttachment
var now int64
var result sql.Result
var n int64
var err error
item, err = s.GetBlockAttachment(boardID, blockID, attachmentID)
if err != nil {
return item, err
}
now = time.Now().Unix()
result, err = s.Exec(`
UPDATE block_attachments SET delete_at = ?
WHERE public_id = ?
AND delete_at = 0
AND block_id = (
SELECT bl.id FROM blocks bl
JOIN boards brd ON brd.id = bl.board_id
WHERE brd.public_id = ? AND bl.public_id = ? AND bl.delete_at = 0
)`, now, attachmentID, boardID, blockID)
if err != nil {
return item, err
}
n, err = result.RowsAffected()
if err != nil {
return item, err
}
if n == 0 {
return item, ErrBlockAttachmentNotFound
}
item.DeleteAt = now
return item, nil
}
func (s *Store) GetBlockProperties(boardID, blockID string) (models.BlockProperties, error) { func (s *Store) GetBlockProperties(boardID, blockID string) (models.BlockProperties, error) {
var bp models.BlockProperties var bp models.BlockProperties
var assigneePublicID sql.NullString
var assigneeName sql.NullString
var err error var err error
err = s.QueryRow(` err = s.QueryRow(`
SELECT bl.public_id, SELECT bl.public_id,
COALESCE(p.status, ''), COALESCE(p.card_type, ''), COALESCE(p.status, ''), COALESCE(p.card_type, ''),
COALESCE(p.priority, ''), COALESCE(p.due_date, ''), COALESCE(p.priority, ''), COALESCE(p.due_date, ''),
u.public_id, u.username, COALESCE(p.sprint, ''), COALESCE(p.description, ''),
COALESCE(p.sprint, ''), COALESCE(p.description, '') COALESCE(ci.total, 0), COALESCE(ci.done, 0),
COALESCE(ba.total, 0), COALESCE(ba.images, 0)
FROM blocks bl FROM blocks bl
JOIN boards brd ON brd.id = bl.board_id JOIN boards brd ON brd.id = bl.board_id
LEFT JOIN block_properties p ON p.block_id = bl.id LEFT JOIN block_properties p ON p.block_id = bl.id
LEFT JOIN users u ON u.id = p.assignee_id LEFT JOIN (
SELECT block_id, COUNT(*) AS total, SUM(CASE WHEN done THEN 1 ELSE 0 END) AS done
FROM block_checklist_items
WHERE delete_at = 0
GROUP BY block_id
) ci ON ci.block_id = bl.id
LEFT JOIN (
SELECT block_id, COUNT(*) AS total, SUM(CASE WHEN content_type LIKE 'image/%' THEN 1 ELSE 0 END) AS images
FROM block_attachments
WHERE delete_at = 0
GROUP BY block_id
) ba ON ba.block_id = bl.id
WHERE brd.public_id = ? AND bl.public_id = ? AND bl.delete_at = 0`, WHERE brd.public_id = ? AND bl.public_id = ? AND bl.delete_at = 0`,
boardID, blockID).Scan( boardID, blockID).Scan(
&bp.BlockID, &bp.BlockID,
&bp.Status, &bp.CardType, &bp.Status, &bp.CardType,
&bp.Priority, &bp.DueDate, &bp.Priority, &bp.DueDate,
&assigneePublicID, &assigneeName, &bp.Sprint, &bp.Description,
&bp.Sprint, &bp.Description) &bp.ChecklistTotal, &bp.ChecklistDone,
&bp.AttachmentTotal, &bp.ImageTotal)
if err != nil { if err != nil {
return bp, err return bp, err
} }
if assigneePublicID.Valid { err = s.populateBlockPropertiesAssignees(boardID, &bp)
bp.AssigneeID = assigneePublicID.String return bp, err
}
if assigneeName.Valid {
bp.AssigneeName = assigneeName.String
}
return bp, nil
} }
func (s *Store) ListAllBlockProperties(boardID string) ([]models.BlockProperties, error) { func (s *Store) ListAllBlockProperties(boardID string) ([]models.BlockProperties, error) {
@@ -176,19 +549,31 @@ func (s *Store) ListAllBlockProperties(boardID string) ([]models.BlockProperties
var err error var err error
var result []models.BlockProperties var result []models.BlockProperties
var bp models.BlockProperties var bp models.BlockProperties
var assigneePublicID sql.NullString var assignees map[string][]models.BoardAssignableUser
var assigneeName sql.NullString var i int
rows, err = s.Query(` rows, err = s.Query(`
SELECT bl.public_id, SELECT bl.public_id,
COALESCE(p.status, ''), COALESCE(p.card_type, ''), COALESCE(p.status, ''), COALESCE(p.card_type, ''),
COALESCE(p.priority, ''), COALESCE(p.due_date, ''), COALESCE(p.priority, ''), COALESCE(p.due_date, ''),
u.public_id, u.username, COALESCE(p.sprint, ''), COALESCE(p.description, ''),
COALESCE(p.sprint, ''), COALESCE(p.description, '') COALESCE(ci.total, 0), COALESCE(ci.done, 0),
COALESCE(ba.total, 0), COALESCE(ba.images, 0)
FROM blocks bl FROM blocks bl
JOIN boards brd ON brd.id = bl.board_id JOIN boards brd ON brd.id = bl.board_id
LEFT JOIN block_properties p ON p.block_id = bl.id LEFT JOIN block_properties p ON p.block_id = bl.id
LEFT JOIN users u ON u.id = p.assignee_id LEFT JOIN (
SELECT block_id, COUNT(*) AS total, SUM(CASE WHEN done THEN 1 ELSE 0 END) AS done
FROM block_checklist_items
WHERE delete_at = 0
GROUP BY block_id
) ci ON ci.block_id = bl.id
LEFT JOIN (
SELECT block_id, COUNT(*) AS total, SUM(CASE WHEN content_type LIKE 'image/%' THEN 1 ELSE 0 END) AS images
FROM block_attachments
WHERE delete_at = 0
GROUP BY block_id
) ba ON ba.block_id = bl.id
WHERE brd.public_id = ? AND bl.type = 'card' AND bl.delete_at = 0 WHERE brd.public_id = ? AND bl.type = 'card' AND bl.delete_at = 0
ORDER BY COALESCE(p.sprint, ''), bl.created_at ASC`, boardID) ORDER BY COALESCE(p.sprint, ''), bl.created_at ASC`, boardID)
if err != nil { if err != nil {
@@ -197,32 +582,134 @@ func (s *Store) ListAllBlockProperties(boardID string) ([]models.BlockProperties
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
bp = models.BlockProperties{} bp = models.BlockProperties{}
assigneePublicID = sql.NullString{}
assigneeName = sql.NullString{}
err = rows.Scan( err = rows.Scan(
&bp.BlockID, &bp.BlockID,
&bp.Status, &bp.CardType, &bp.Status, &bp.CardType,
&bp.Priority, &bp.DueDate, &bp.Priority, &bp.DueDate,
&assigneePublicID, &assigneeName, &bp.Sprint, &bp.Description,
&bp.Sprint, &bp.Description) &bp.ChecklistTotal, &bp.ChecklistDone,
&bp.AttachmentTotal, &bp.ImageTotal)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if assigneePublicID.Valid {
bp.AssigneeID = assigneePublicID.String
}
if assigneeName.Valid {
bp.AssigneeName = assigneeName.String
}
result = append(result, bp) result = append(result, bp)
} }
err = rows.Err()
if err != nil {
return nil, err
}
assignees, err = s.listBlockAssigneesForBoard(boardID)
if err != nil {
return nil, err
}
for i = 0; i < len(result); i++ {
setBlockPropertiesAssignees(&result[i], assignees[result[i].BlockID])
}
return result, nil
}
func normalizeBlockAssigneeIDs(props models.BlockProperties) []string {
var seen map[string]bool
var ids []string
var id string
var i int
seen = map[string]bool{}
for i = 0; i < len(props.AssigneeIDs); i++ {
id = strings.TrimSpace(props.AssigneeIDs[i])
if id == "" || seen[id] {
continue
}
seen[id] = true
ids = append(ids, id)
}
for i = 0; i < len(props.Assignees); i++ {
id = strings.TrimSpace(props.Assignees[i].ID)
if id == "" || seen[id] {
continue
}
seen[id] = true
ids = append(ids, id)
}
id = strings.TrimSpace(props.AssigneeID)
if id != "" && !seen[id] {
ids = append(ids, id)
}
return ids
}
func setBlockPropertiesAssignees(bp *models.BlockProperties, assignees []models.BoardAssignableUser) {
var i int
bp.Assignees = assignees
bp.AssigneeIDs = []string{}
bp.AssigneeID = ""
bp.AssigneeName = ""
for i = 0; i < len(assignees); i++ {
bp.AssigneeIDs = append(bp.AssigneeIDs, assignees[i].ID)
}
if len(assignees) > 0 {
bp.AssigneeID = assignees[0].ID
bp.AssigneeName = assignees[0].DisplayName
if bp.AssigneeName == "" {
bp.AssigneeName = assignees[0].Username
}
}
}
func (s *Store) populateBlockPropertiesAssignees(boardID string, bp *models.BlockProperties) error {
var all map[string][]models.BoardAssignableUser
var err error
all, err = s.listBlockAssigneesForBoard(boardID)
if err != nil {
return err
}
setBlockPropertiesAssignees(bp, all[bp.BlockID])
return nil
}
func (s *Store) listBlockAssigneesForBoard(boardID string) (map[string][]models.BoardAssignableUser, error) {
var rows *sql.Rows
var err error
var result map[string][]models.BoardAssignableUser
var blockID string
var user models.BoardAssignableUser
result = map[string][]models.BoardAssignableUser{}
rows, err = s.Query(`
SELECT bl.public_id, u.public_id, u.username, u.display_name,
CASE WHEN u.avatar_storage_path != '' THEN '/api/users/' || u.public_id || '/avatar?v=' || u.avatar_updated_at ELSE '' END
FROM block_assignees ba
JOIN blocks bl ON bl.id = ba.block_id
JOIN boards brd ON brd.id = bl.board_id
JOIN users u ON u.id = ba.user_id
WHERE brd.public_id = ? AND brd.delete_at = 0 AND bl.delete_at = 0 AND u.disabled = 0
ORDER BY ba.assigned_at ASC, u.username ASC`, boardID)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
user = models.BoardAssignableUser{}
err = rows.Scan(&blockID, &user.ID, &user.Username, &user.DisplayName, &user.AvatarURL)
if err != nil {
return nil, err
}
result[blockID] = append(result[blockID], user)
}
return result, rows.Err() return result, rows.Err()
} }
func (s *Store) UpsertBlockProperties(boardID, blockID string, props models.BlockProperties) (models.BlockProperties, error) { func (s *Store) UpsertBlockProperties(boardID, blockID string, props models.BlockProperties) (models.BlockProperties, error) {
var internalBlockID int64 var internalBlockID int64
var internalAssigneeID sql.NullInt64
var err error var err error
var tx *sql.Tx
var owned bool
var assigneeIDs []string
var i int
var result sql.Result
var affected int64
err = s.QueryRow(` err = s.QueryRow(`
SELECT bl.id FROM blocks bl SELECT bl.id FROM blocks bl
@@ -236,21 +723,14 @@ func (s *Store) UpsertBlockProperties(boardID, blockID string, props models.Bloc
return props, err return props, err
} }
if props.AssigneeID != "" { assigneeIDs = normalizeBlockAssigneeIDs(props)
var aid int64 tx, owned, err = s.begin()
err = s.QueryRow(`SELECT id FROM users WHERE public_id = ?`, props.AssigneeID).Scan(&aid)
if err == sql.ErrNoRows {
return props, ErrUserNotFound
}
if err != nil { if err != nil {
return props, err return props, err
} }
internalAssigneeID = sql.NullInt64{Int64: aid, Valid: true} _, err = tx.Exec(`
}
_, err = s.Exec(`
INSERT INTO block_properties (block_id, status, card_type, priority, due_date, assignee_id, sprint, description) INSERT INTO block_properties (block_id, status, card_type, priority, due_date, assignee_id, sprint, description)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, NULL, ?, ?)
ON CONFLICT(block_id) DO UPDATE SET ON CONFLICT(block_id) DO UPDATE SET
status = excluded.status, status = excluded.status,
card_type = excluded.card_type, card_type = excluded.card_type,
@@ -260,16 +740,48 @@ func (s *Store) UpsertBlockProperties(boardID, blockID string, props models.Bloc
sprint = excluded.sprint, sprint = excluded.sprint,
description = excluded.description`, description = excluded.description`,
internalBlockID, props.Status, props.CardType, internalBlockID, props.Status, props.CardType,
props.Priority, props.DueDate, internalAssigneeID, props.Priority, props.DueDate,
props.Sprint, props.Description) props.Sprint, props.Description)
if err != nil { if err != nil {
rollbackIfOwned(tx, owned)
return props, err return props, err
} }
_, err = tx.Exec(`DELETE FROM block_assignees WHERE block_id = ?`, internalBlockID)
if err != nil {
rollbackIfOwned(tx, owned)
return props, err
}
for i = 0; i < len(assigneeIDs); i++ {
result, err = tx.Exec(`
INSERT INTO block_assignees (block_id, user_id, assigned_at)
SELECT ?, u.id, ?
FROM users u
WHERE u.public_id = ? AND u.disabled = 0`,
internalBlockID, time.Now().Unix(), assigneeIDs[i])
if err != nil {
rollbackIfOwned(tx, owned)
return props, err
}
affected, err = result.RowsAffected()
if err != nil {
rollbackIfOwned(tx, owned)
return props, err
}
if affected == 0 {
rollbackIfOwned(tx, owned)
return props, ErrUserNotFound
}
}
// Keep blocks.fields status in sync so the kanban view can group without extra queries. // Keep blocks.fields status in sync so the kanban view can group without extra queries.
_, err = s.Exec(` _, err = tx.Exec(`
UPDATE blocks SET fields = json_set(COALESCE(fields, '{}'), '$.status', ?), updated_at = ? UPDATE blocks SET fields = json_set(COALESCE(fields, '{}'), '$.status', ?), updated_at = ?
WHERE id = ?`, props.Status, time.Now().Unix(), internalBlockID) WHERE id = ?`, props.Status, time.Now().Unix(), internalBlockID)
if err != nil {
rollbackIfOwned(tx, owned)
return props, err
}
err = commitIfOwned(tx, owned)
if err != nil { if err != nil {
return props, err return props, err
} }
+58
View File
@@ -828,6 +828,64 @@ func (s *Store) ListBoardMembers(boardID string) ([]models.BoardMember, error) {
return members, rows.Err() return members, rows.Err()
} }
func (s *Store) ListBoardAssignableUsers(boardID string) ([]models.BoardAssignableUser, error) {
var rows *sql.Rows
var err error
var users []models.BoardAssignableUser
var u models.BoardAssignableUser
rows, err = s.Query(`
SELECT DISTINCT u.public_id, u.username, u.display_name,
CASE WHEN u.avatar_storage_path != '' THEN '/api/users/' || u.public_id || '/avatar?v=' || u.avatar_updated_at ELSE '' END
FROM users u
WHERE u.disabled = 0
AND EXISTS (SELECT 1 FROM boards b WHERE b.public_id = ? AND b.delete_at = 0)
AND (
u.is_admin = 1
OR EXISTS (
SELECT 1 FROM board_members bm
JOIN boards b ON b.id = bm.board_id
WHERE b.public_id = ? AND b.delete_at = 0 AND bm.user_id = u.id
)
OR EXISTS (
SELECT 1 FROM project_role_bindings prb
JOIN boards b ON b.project_id = prb.project_id
WHERE b.public_id = ? AND b.delete_at = 0
AND prb.subject_type = 'user'
AND prb.subject_id = u.id
)
OR EXISTS (
SELECT 1 FROM project_role_bindings prb
JOIN boards b ON b.project_id = prb.project_id
JOIN user_groups ug ON ug.id = prb.subject_id
WHERE b.public_id = ? AND b.delete_at = 0
AND prb.subject_type = 'group'
AND ug.disabled = 0
AND (
ug.scope = 'all_users'
OR EXISTS (
SELECT 1 FROM user_group_members ugm
WHERE ugm.group_id = ug.id AND ugm.user_id = u.id
)
)
)
)
ORDER BY COALESCE(NULLIF(u.display_name, ''), u.username), u.username`, boardID, boardID, boardID, boardID)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
u = models.BoardAssignableUser{}
err = rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.AvatarURL)
if err != nil {
return nil, err
}
users = append(users, u)
}
return users, rows.Err()
}
func (s *Store) UpsertBoardMember(member models.BoardMember) error { func (s *Store) UpsertBoardMember(member models.BoardMember) error {
var err error var err error
var nowUnix int64 var nowUnix int64
+43 -9
View File
@@ -73,8 +73,8 @@ func (s *Store) GetUserByID(id string) (models.User, error) {
var user models.User var user models.User
var row *sql.Row var row *sql.Row
var err error var err error
row = s.QueryRow(`SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, COALESCE(ap.public_id, ''), COALESCE(t.enabled, 0), u.totp_required, u.created_at, u.updated_at FROM users u LEFT JOIN auth_providers ap ON ap.id = u.auth_provider_id LEFT JOIN user_totp t ON t.user_id = u.id WHERE u.public_id = ?`, id) row = s.QueryRow(`SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, COALESCE(ap.public_id, ''), COALESCE(t.enabled, 0), u.totp_required, CASE WHEN u.avatar_storage_path != '' THEN '/api/users/' || u.public_id || '/avatar?v=' || u.avatar_updated_at ELSE '' END, u.avatar_updated_at, u.created_at, u.updated_at FROM users u LEFT JOIN auth_providers ap ON ap.id = u.auth_provider_id LEFT JOIN user_totp t ON t.user_id = u.id WHERE u.public_id = ?`, id)
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthProviderID, &user.TOTPEnabled, &user.TOTPRequired, &user.CreatedAt, &user.UpdatedAt) err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthProviderID, &user.TOTPEnabled, &user.TOTPRequired, &user.AvatarURL, &user.AvatarUpdatedAt, &user.CreatedAt, &user.UpdatedAt)
if err != nil { if err != nil {
return user, err return user, err
} }
@@ -86,8 +86,8 @@ func (s *Store) GetUserByUsername(username string) (models.User, string, error)
var passwordHash sql.NullString var passwordHash sql.NullString
var row *sql.Row var row *sql.Row
var err error var err error
row = s.QueryRow(`SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, COALESCE(ap.public_id, ''), u.password_hash, COALESCE(t.enabled, 0), u.totp_required, u.created_at, u.updated_at FROM users u LEFT JOIN auth_providers ap ON ap.id = u.auth_provider_id LEFT JOIN user_totp t ON t.user_id = u.id WHERE u.username = ?`, username) row = s.QueryRow(`SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, COALESCE(ap.public_id, ''), u.password_hash, COALESCE(t.enabled, 0), u.totp_required, CASE WHEN u.avatar_storage_path != '' THEN '/api/users/' || u.public_id || '/avatar?v=' || u.avatar_updated_at ELSE '' END, u.avatar_updated_at, u.created_at, u.updated_at FROM users u LEFT JOIN auth_providers ap ON ap.id = u.auth_provider_id LEFT JOIN user_totp t ON t.user_id = u.id WHERE u.username = ?`, username)
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthProviderID, &passwordHash, &user.TOTPEnabled, &user.TOTPRequired, &user.CreatedAt, &user.UpdatedAt) err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthProviderID, &passwordHash, &user.TOTPEnabled, &user.TOTPRequired, &user.AvatarURL, &user.AvatarUpdatedAt, &user.CreatedAt, &user.UpdatedAt)
if err != nil { if err != nil {
return user, passwordHash.String, err return user, passwordHash.String, err
} }
@@ -99,13 +99,14 @@ func (s *Store) ListUsers() ([]models.User, error) {
var err error var err error
var users []models.User var users []models.User
var u models.User var u models.User
rows, err = s.Query(`SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, COALESCE(ap.public_id, ''), COALESCE(t.enabled, 0), u.totp_required, u.created_at, u.updated_at FROM users u LEFT JOIN auth_providers ap ON ap.id = u.auth_provider_id LEFT JOIN user_totp t ON t.user_id = u.id ORDER BY u.username`) rows, err = s.Query(`SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, COALESCE(ap.public_id, ''), COALESCE(t.enabled, 0), u.totp_required, CASE WHEN u.avatar_storage_path != '' THEN '/api/users/' || u.public_id || '/avatar?v=' || u.avatar_updated_at ELSE '' END, u.avatar_updated_at, u.created_at, u.updated_at FROM users u LEFT JOIN auth_providers ap ON ap.id = u.auth_provider_id LEFT JOIN user_totp t ON t.user_id = u.id ORDER BY u.username`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
err = rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.IsAdmin, &u.Disabled, &u.AuthProviderID, &u.TOTPEnabled, &u.TOTPRequired, &u.CreatedAt, &u.UpdatedAt) u = models.User{}
err = rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.IsAdmin, &u.Disabled, &u.AuthProviderID, &u.TOTPEnabled, &u.TOTPRequired, &u.AvatarURL, &u.AvatarUpdatedAt, &u.CreatedAt, &u.UpdatedAt)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -114,6 +115,34 @@ func (s *Store) ListUsers() ([]models.User, error) {
return users, rows.Err() return users, rows.Err()
} }
func (s *Store) GetUserAvatar(id string) (models.UserAvatar, error) {
var avatar models.UserAvatar
var row *sql.Row
var err error
row = s.QueryRow(`SELECT public_id, avatar_storage_path, avatar_content_type, avatar_updated_at FROM users WHERE public_id = ? AND avatar_storage_path != ''`, id)
err = row.Scan(&avatar.UserID, &avatar.StoragePath, &avatar.ContentType, &avatar.UpdatedAt)
if err != nil {
return avatar, err
}
return avatar, nil
}
func (s *Store) UpdateUserAvatar(id string, storagePath string, contentType string) error {
var err error
var nowUnix int64
nowUnix = time.Now().UTC().Unix()
_, err = s.Exec(`UPDATE users SET avatar_storage_path = ?, avatar_content_type = ?, avatar_updated_at = ?, updated_at = ? WHERE public_id = ?`,
storagePath, contentType, nowUnix, nowUnix, id)
return err
}
func (s *Store) ClearUserAvatar(id string) error {
var err error
_, err = s.Exec(`UPDATE users SET avatar_storage_path = '', avatar_content_type = '', avatar_updated_at = 0, updated_at = ? WHERE public_id = ?`,
time.Now().UTC().Unix(), id)
return err
}
func (s *Store) DeleteUser(id string) error { func (s *Store) DeleteUser(id string) error {
var tx *sql.Tx var tx *sql.Tx
var owned bool var owned bool
@@ -152,14 +181,17 @@ func (s *Store) GetUserByProviderAndSub(providerPublicID string, sub string) (mo
var err error var err error
row = s.QueryRow(` row = s.QueryRow(`
SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled,
COALESCE(ap.public_id, ''), u.external_subject, COALESCE(t.enabled, 0), u.totp_required, u.created_at, u.updated_at COALESCE(ap.public_id, ''), u.external_subject, COALESCE(t.enabled, 0), u.totp_required,
CASE WHEN u.avatar_storage_path != '' THEN '/api/users/' || u.public_id || '/avatar?v=' || u.avatar_updated_at ELSE '' END,
u.avatar_updated_at, u.created_at, u.updated_at
FROM users u FROM users u
LEFT JOIN auth_providers ap ON ap.id = u.auth_provider_id LEFT JOIN auth_providers ap ON ap.id = u.auth_provider_id
LEFT JOIN user_totp t ON t.user_id = u.id LEFT JOIN user_totp t ON t.user_id = u.id
WHERE ap.public_id = ? AND u.external_subject = ? WHERE ap.public_id = ? AND u.external_subject = ?
`, strings.TrimSpace(providerPublicID), strings.TrimSpace(sub)) `, strings.TrimSpace(providerPublicID), strings.TrimSpace(sub))
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled,
&user.AuthProviderID, &user.ExternalSubject, &user.TOTPEnabled, &user.TOTPRequired, &user.CreatedAt, &user.UpdatedAt) &user.AuthProviderID, &user.ExternalSubject, &user.TOTPEnabled, &user.TOTPRequired,
&user.AvatarURL, &user.AvatarUpdatedAt, &user.CreatedAt, &user.UpdatedAt)
if err != nil { if err != nil {
return user, err return user, err
} }
@@ -649,6 +681,8 @@ func (s *Store) GetSessionUser(token string) (models.User, time.Time, bool, erro
var err error var err error
row = s.QueryRow(` row = s.QueryRow(`
SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, COALESCE(ap.public_id, ''), COALESCE(t.enabled, 0), u.totp_required, SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, COALESCE(ap.public_id, ''), COALESCE(t.enabled, 0), u.totp_required,
CASE WHEN u.avatar_storage_path != '' THEN '/api/users/' || u.public_id || '/avatar?v=' || u.avatar_updated_at ELSE '' END,
u.avatar_updated_at,
(u.totp_required OR EXISTS ( (u.totp_required OR EXISTS (
SELECT 1 FROM user_group_members m JOIN user_groups g ON g.id = m.group_id SELECT 1 FROM user_group_members m JOIN user_groups g ON g.id = m.group_id
WHERE m.user_id = u.id AND g.disabled = 0 AND g.totp_required = 1 WHERE m.user_id = u.id AND g.disabled = 0 AND g.totp_required = 1
@@ -662,7 +696,7 @@ func (s *Store) GetSessionUser(token string) (models.User, time.Time, bool, erro
LEFT JOIN auth_providers ap ON ap.id = u.auth_provider_id LEFT JOIN auth_providers ap ON ap.id = u.auth_provider_id
WHERE s.token = ? AND u.disabled = 0 WHERE s.token = ? AND u.disabled = 0
`, token) `, token)
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthProviderID, &user.TOTPEnabled, &user.TOTPRequired, &user.TOTPEffectiveRequired, &totpVerified, &user.CreatedAt, &user.UpdatedAt, &expiresUnix) err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthProviderID, &user.TOTPEnabled, &user.TOTPRequired, &user.AvatarURL, &user.AvatarUpdatedAt, &user.TOTPEffectiveRequired, &totpVerified, &user.CreatedAt, &user.UpdatedAt, &expiresUnix)
if err != nil { if err != nil {
return user, time.Time{}, false, err return user, time.Time{}, false, err
} }
+194
View File
@@ -657,6 +657,200 @@ func (api *API) UpdateMe(w http.ResponseWriter, r *http.Request, _ map[string]st
WriteJSON(w, http.StatusOK, user) WriteJSON(w, http.StatusOK, user)
} }
const userAvatarMaxBytes int64 = 2 * 1024 * 1024
func avatarFileExt(contentType string) string {
switch contentType {
case "image/jpeg":
return ".jpg"
case "image/png":
return ".png"
case "image/gif":
return ".gif"
case "image/webp":
return ".webp"
}
return ""
}
func detectAvatarContentType(file multipart.File) (string, error) {
var buf [512]byte
var n int
var err error
n, err = file.Read(buf[:])
if err != nil && err != io.EOF {
return "", err
}
_, err = file.Seek(0, io.SeekStart)
if err != nil {
return "", err
}
return http.DetectContentType(buf[:n]), nil
}
func (api *API) UploadMyAvatar(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var ctxUser models.User
var user models.User
var oldAvatar models.UserAvatar
var ok bool
var file multipart.File
var err error
var oldErr error
var contentType string
var ext string
var avatarDir string
var storedName string
var tempPath string
var finalPath string
var out *os.File
var copied int64
var ts string
var closeErr error
ctxUser, ok = middleware.UserFromContext(r.Context())
if !ok {
w.WriteHeader(http.StatusUnauthorized)
return
}
r.Body = http.MaxBytesReader(w, r.Body, userAvatarMaxBytes+1024*1024)
file, _, err = r.FormFile("avatar")
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "avatar file is required")
return
}
defer file.Close()
contentType, err = detectAvatarContentType(file)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "failed to read avatar")
return
}
ext = avatarFileExt(contentType)
if ext == "" {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "avatar must be jpeg, png, gif, or webp")
return
}
avatarDir = filepath.Join(api.Uploads.BaseDir, "avatars")
err = os.MkdirAll(avatarDir, 0o755)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
ts = strconv.FormatInt(time.Now().UnixNano(), 10)
storedName = "user-" + ctxUser.ID + "-" + ts + ext
tempPath = filepath.Join(avatarDir, storedName+".uploading-"+ts)
finalPath = filepath.Join(avatarDir, storedName)
out, err = os.Create(tempPath)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
copied, err = io.Copy(out, file)
closeErr = out.Close()
if err == nil {
err = closeErr
}
if err != nil {
_ = os.Remove(tempPath)
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if copied > userAvatarMaxBytes {
_ = os.Remove(tempPath)
WriteJSONWithErrorReason(w, r, http.StatusRequestEntityTooLarge, "avatar is too large")
return
}
err = os.Rename(tempPath, finalPath)
if err != nil {
_ = os.Remove(tempPath)
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
oldAvatar, oldErr = api.store(r).GetUserAvatar(ctxUser.ID)
err = api.store(r).UpdateUserAvatar(ctxUser.ID, finalPath, contentType)
if err != nil {
_ = os.Remove(finalPath)
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if oldErr == nil && oldAvatar.StoragePath != "" && oldAvatar.StoragePath != finalPath {
_ = os.Remove(oldAvatar.StoragePath)
}
user, err = api.store(r).GetUserByID(ctxUser.ID)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
WriteJSON(w, http.StatusOK, user)
}
func (api *API) DeleteMyAvatar(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var ctxUser models.User
var user models.User
var avatar models.UserAvatar
var ok bool
var err error
ctxUser, ok = middleware.UserFromContext(r.Context())
if !ok {
w.WriteHeader(http.StatusUnauthorized)
return
}
avatar, err = api.store(r).GetUserAvatar(ctxUser.ID)
if err != nil && err != sql.ErrNoRows {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
err = api.store(r).ClearUserAvatar(ctxUser.ID)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if avatar.StoragePath != "" {
_ = os.Remove(avatar.StoragePath)
}
user, err = api.store(r).GetUserByID(ctxUser.ID)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
WriteJSON(w, http.StatusOK, user)
}
func (api *API) GetUserAvatar(w http.ResponseWriter, r *http.Request, params map[string]string) {
var avatar models.UserAvatar
var file *os.File
var stat os.FileInfo
var err error
var modTime time.Time
avatar, err = api.store(r).GetUserAvatar(params["id"])
if err != nil {
if err == sql.ErrNoRows {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "avatar not found")
return
}
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
file, err = api.Uploads.Open(avatar.StoragePath)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "avatar not found")
return
}
defer file.Close()
stat, err = file.Stat()
if err == nil {
modTime = stat.ModTime()
}
if avatar.UpdatedAt > 0 {
modTime = time.Unix(avatar.UpdatedAt, 0)
}
w.Header().Set("Content-Type", avatar.ContentType)
w.Header().Set("Cache-Control", "private, max-age=86400")
http.ServeContent(w, r, "avatar", modTime, file)
}
func (api *API) ListUsers(w http.ResponseWriter, r *http.Request, _ map[string]string) { func (api *API) ListUsers(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var users []models.User var users []models.User
var err error var err error
+304
View File
@@ -2,11 +2,19 @@ package handlers
import "database/sql" import "database/sql"
import "errors" import "errors"
import "io"
import "mime/multipart"
import "net/http" import "net/http"
import "os"
import "path/filepath"
import "strconv"
import "strings"
import "time"
import "codit/internal/db" import "codit/internal/db"
import "codit/internal/middleware" import "codit/internal/middleware"
import "codit/internal/models" import "codit/internal/models"
import "codit/internal/util"
func (api *API) ListBlockComments(w http.ResponseWriter, r *http.Request, params map[string]string) { func (api *API) ListBlockComments(w http.ResponseWriter, r *http.Request, params map[string]string) {
var comments []models.BlockComment var comments []models.BlockComment
@@ -134,6 +142,300 @@ func (api *API) DeleteBlockComment(w http.ResponseWriter, r *http.Request, param
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
func (api *API) ListBlockChecklistItems(w http.ResponseWriter, r *http.Request, params map[string]string) {
var items []models.BlockChecklistItem
var err error
if !api.requireBoardRole(w, r, params["boardId"], "viewer") {
return
}
items, err = api.store(r).ListBlockChecklistItems(params["boardId"], params["blockId"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if items == nil {
items = []models.BlockChecklistItem{}
}
WriteJSON(w, http.StatusOK, items)
}
type createBlockChecklistItemRequest struct {
Title string `json:"title"`
}
func (api *API) CreateBlockChecklistItem(w http.ResponseWriter, r *http.Request, params map[string]string) {
var req createBlockChecklistItemRequest
var user models.User
var ok bool
var item models.BlockChecklistItem
var err error
user, ok = middleware.UserFromContext(r.Context())
if !ok {
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "requires user account")
return
}
if !api.requireBoardRole(w, r, params["boardId"], "editor") {
return
}
err = DecodeJSON(r, &req)
if err != nil || req.Title == "" {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "title is required")
return
}
item, err = api.store(r).CreateBlockChecklistItem(params["boardId"], params["blockId"], req.Title, user.ID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "block not found")
return
}
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
WriteJSON(w, http.StatusCreated, item)
}
type updateBlockChecklistItemRequest struct {
Title string `json:"title"`
Done bool `json:"done"`
}
func (api *API) UpdateBlockChecklistItem(w http.ResponseWriter, r *http.Request, params map[string]string) {
var req updateBlockChecklistItemRequest
var user models.User
var ok bool
var item models.BlockChecklistItem
var err error
user, ok = middleware.UserFromContext(r.Context())
if !ok {
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "requires user account")
return
}
if !api.requireBoardRole(w, r, params["boardId"], "editor") {
return
}
err = DecodeJSON(r, &req)
if err != nil || req.Title == "" {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "title is required")
return
}
item, err = api.store(r).UpdateBlockChecklistItem(params["boardId"], params["blockId"], params["itemId"], req.Title, req.Done, user.ID)
if err != nil {
if errors.Is(err, db.ErrChecklistItemNotFound) {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "checklist item not found")
return
}
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
WriteJSON(w, http.StatusOK, item)
}
func (api *API) DeleteBlockChecklistItem(w http.ResponseWriter, r *http.Request, params map[string]string) {
var err error
if !api.requireBoardRole(w, r, params["boardId"], "editor") {
return
}
err = api.store(r).DeleteBlockChecklistItem(params["boardId"], params["blockId"], params["itemId"])
if err != nil {
if errors.Is(err, db.ErrChecklistItemNotFound) {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "checklist item not found")
return
}
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func (api *API) ReorderBlockChecklistItems(w http.ResponseWriter, r *http.Request, params map[string]string) {
var req struct {
IDs []string `json:"ids"`
}
var items []models.BlockChecklistItem
var err error
if !api.requireBoardRole(w, r, params["boardId"], "editor") {
return
}
err = DecodeJSON(r, &req)
if err != nil || len(req.IDs) == 0 {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "ids are required")
return
}
items, err = api.store(r).ReorderBlockChecklistItems(params["boardId"], params["blockId"], req.IDs)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "block not found")
return
}
if errors.Is(err, db.ErrChecklistItemNotFound) {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "checklist item not found")
return
}
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if items == nil {
items = []models.BlockChecklistItem{}
}
WriteJSON(w, http.StatusOK, items)
}
func (api *API) ListBlockAttachments(w http.ResponseWriter, r *http.Request, params map[string]string) {
var items []models.BlockAttachment
var err error
if !api.requireBoardRole(w, r, params["boardId"], "viewer") {
return
}
items, err = api.store(r).ListBlockAttachments(params["boardId"], params["blockId"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if items == nil {
items = []models.BlockAttachment{}
}
WriteJSON(w, http.StatusOK, items)
}
func (api *API) CreateBlockAttachment(w http.ResponseWriter, r *http.Request, params map[string]string) {
var user models.User
var ok bool
var file multipart.File
var header *multipart.FileHeader
var err error
var id string
var storedName string
var tempName string
var tempPath string
var finalPath string
var size int64
var contentType string
var attachment models.BlockAttachment
var created models.BlockAttachment
var ts string
if !api.requireBoardRole(w, r, params["boardId"], "editor") {
return
}
user, ok = middleware.UserFromContext(r.Context())
if !ok {
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "requires user account")
return
}
file, header, err = r.FormFile("file")
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "file required")
return
}
defer file.Close()
id, err = util.NewID()
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, "failed to generate id")
return
}
storedName = "board-attachment-" + id + filepath.Ext(header.Filename)
ts = strconv.FormatInt(time.Now().UnixNano(), 10)
tempName = storedName + ".uploading-" + ts
tempPath, size, err = api.Uploads.Save(tempName, file)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
finalPath = filepath.Join(api.Uploads.BaseDir, storedName)
err = os.Rename(tempPath, finalPath)
if err != nil {
_ = os.Remove(tempPath)
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
contentType = header.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
attachment = models.BlockAttachment{
ID: id,
BlockID: params["blockId"],
Filename: header.Filename,
ContentType: contentType,
Size: size,
StoragePath: finalPath,
CreatedBy: user.ID,
}
created, err = api.store(r).CreateBlockAttachment(params["boardId"], params["blockId"], attachment)
if err != nil {
_ = os.Remove(finalPath)
if errors.Is(err, sql.ErrNoRows) {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "block not found")
return
}
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
WriteJSON(w, http.StatusCreated, created)
}
func (api *API) DownloadBlockAttachment(w http.ResponseWriter, r *http.Request, params map[string]string) {
var item models.BlockAttachment
var err error
var file *os.File
var inline bool
var stat os.FileInfo
if !api.requireBoardRole(w, r, params["boardId"], "viewer") {
return
}
item, err = api.store(r).GetBlockAttachment(params["boardId"], params["blockId"], params["attachmentId"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "attachment not found")
return
}
file, err = api.Uploads.Open(item.StoragePath)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
defer file.Close()
stat, err = file.Stat()
if err == nil {
w.Header().Set("Content-Length", strconv.FormatInt(stat.Size(), 10))
}
inline = r.URL.Query().Get("inline") == "1" && strings.HasPrefix(item.ContentType, "image/")
w.Header().Set("Content-Type", item.ContentType)
if inline {
w.Header().Set("Content-Disposition", "inline; filename=\""+sanitizeFilename(item.Filename)+"\"")
} else {
w.Header().Set("Content-Disposition", "attachment; filename=\""+sanitizeFilename(item.Filename)+"\"")
}
_, _ = io.Copy(w, file)
}
func (api *API) DeleteBlockAttachment(w http.ResponseWriter, r *http.Request, params map[string]string) {
var item models.BlockAttachment
var err error
if !api.requireBoardRole(w, r, params["boardId"], "editor") {
return
}
item, err = api.store(r).DeleteBlockAttachment(params["boardId"], params["blockId"], params["attachmentId"])
if err != nil {
if errors.Is(err, db.ErrBlockAttachmentNotFound) {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "attachment not found")
return
}
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
_ = os.Remove(item.StoragePath)
w.WriteHeader(http.StatusNoContent)
}
func (api *API) ListBoardBlockProperties(w http.ResponseWriter, r *http.Request, params map[string]string) { func (api *API) ListBoardBlockProperties(w http.ResponseWriter, r *http.Request, params map[string]string) {
var props []models.BlockProperties var props []models.BlockProperties
var err error var err error
@@ -177,6 +479,7 @@ type upsertBlockPropertiesRequest struct {
Priority string `json:"priority"` Priority string `json:"priority"`
DueDate string `json:"due_date"` DueDate string `json:"due_date"`
AssigneeID string `json:"assignee_id"` AssigneeID string `json:"assignee_id"`
AssigneeIDs []string `json:"assignee_ids"`
Sprint string `json:"sprint"` Sprint string `json:"sprint"`
Description string `json:"description"` Description string `json:"description"`
} }
@@ -200,6 +503,7 @@ func (api *API) UpsertBlockProperties(w http.ResponseWriter, r *http.Request, pa
Priority: req.Priority, Priority: req.Priority,
DueDate: req.DueDate, DueDate: req.DueDate,
AssigneeID: req.AssigneeID, AssigneeID: req.AssigneeID,
AssigneeIDs: req.AssigneeIDs,
Sprint: req.Sprint, Sprint: req.Sprint,
Description: req.Description, Description: req.Description,
} }
+18
View File
@@ -612,6 +612,24 @@ func (api *API) ListBoardMembers(w http.ResponseWriter, r *http.Request, params
WriteJSON(w, http.StatusOK, members) WriteJSON(w, http.StatusOK, members)
} }
func (api *API) ListBoardAssignableUsers(w http.ResponseWriter, r *http.Request, params map[string]string) {
var users []models.BoardAssignableUser
var err error
if !api.requireBoardRole(w, r, params["boardId"], "viewer") {
return
}
users, err = api.store(r).ListBoardAssignableUsers(params["boardId"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if users == nil {
users = []models.BoardAssignableUser{}
}
WriteJSON(w, http.StatusOK, users)
}
func (api *API) AddBoardMember(w http.ResponseWriter, r *http.Request, params map[string]string) { func (api *API) AddBoardMember(w http.ResponseWriter, r *http.Request, params map[string]string) {
var req upsertBoardMemberRequest var req upsertBoardMemberRequest
var err error var err error
@@ -25,6 +25,8 @@ type meResponse struct {
TOTPEffectiveRequired bool `json:"totp_effective_required"` TOTPEffectiveRequired bool `json:"totp_effective_required"`
TOTPSetupRequired bool `json:"totp_setup_required"` TOTPSetupRequired bool `json:"totp_setup_required"`
TOTPVerifyRequired bool `json:"totp_verify_required"` TOTPVerifyRequired bool `json:"totp_verify_required"`
AvatarURL string `json:"avatar_url"`
AvatarUpdatedAt int64 `json:"avatar_updated_at"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
Permissions []string `json:"permissions,omitempty"` Permissions []string `json:"permissions,omitempty"`
@@ -185,6 +187,8 @@ func buildMeResponse(user models.User, permissions []string) meResponse {
TOTPEffectiveRequired: user.TOTPEffectiveRequired, TOTPEffectiveRequired: user.TOTPEffectiveRequired,
TOTPSetupRequired: user.TOTPSetupRequired, TOTPSetupRequired: user.TOTPSetupRequired,
TOTPVerifyRequired: false, TOTPVerifyRequired: false,
AvatarURL: user.AvatarURL,
AvatarUpdatedAt: user.AvatarUpdatedAt,
CreatedAt: user.CreatedAt, CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt, UpdatedAt: user.UpdatedAt,
Permissions: permissions, Permissions: permissions,
+50 -1
View File
@@ -13,10 +13,21 @@ type User struct {
TOTPRequired bool `json:"totp_required"` TOTPRequired bool `json:"totp_required"`
TOTPEffectiveRequired bool `json:"totp_effective_required"` TOTPEffectiveRequired bool `json:"totp_effective_required"`
TOTPSetupRequired bool `json:"totp_setup_required"` TOTPSetupRequired bool `json:"totp_setup_required"`
AvatarURL string `json:"avatar_url"`
AvatarStoragePath string `json:"-"`
AvatarContentType string `json:"-"`
AvatarUpdatedAt int64 `json:"avatar_updated_at"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
} }
type UserAvatar struct {
UserID string
StoragePath string
ContentType string
UpdatedAt int64
}
type AuthProvider struct { type AuthProvider struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -232,7 +243,7 @@ type Upload struct {
Filename string `json:"filename"` Filename string `json:"filename"`
ContentType string `json:"content_type"` ContentType string `json:"content_type"`
Size int64 `json:"size"` Size int64 `json:"size"`
StoragePath string `json:"storage_path"` StoragePath string `json:"-"`
CreatedBy string `json:"created_by"` CreatedBy string `json:"created_by"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
} }
@@ -780,6 +791,13 @@ type BoardMember struct {
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
} }
type BoardAssignableUser struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
}
type BlockComment struct { type BlockComment struct {
ID string `json:"id"` ID string `json:"id"`
BlockID string `json:"block_id"` BlockID string `json:"block_id"`
@@ -792,6 +810,31 @@ type BlockComment struct {
DeleteAt int64 `json:"delete_at,omitempty"` DeleteAt int64 `json:"delete_at,omitempty"`
} }
type BlockChecklistItem struct {
ID string `json:"id"`
BlockID string `json:"block_id"`
Title string `json:"title"`
Done bool `json:"done"`
DisplayOrder int `json:"display_order"`
CreatedBy string `json:"created_by"`
UpdatedBy string `json:"updated_by"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
DeleteAt int64 `json:"delete_at,omitempty"`
}
type BlockAttachment struct {
ID string `json:"id"`
BlockID string `json:"block_id"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
Size int64 `json:"size"`
StoragePath string `json:"storage_path"`
CreatedBy string `json:"created_by"`
CreatedAt int64 `json:"created_at"`
DeleteAt int64 `json:"delete_at,omitempty"`
}
type BlockProperties struct { type BlockProperties struct {
BlockID string `json:"block_id"` BlockID string `json:"block_id"`
Status string `json:"status"` Status string `json:"status"`
@@ -800,8 +843,14 @@ type BlockProperties struct {
DueDate string `json:"due_date"` DueDate string `json:"due_date"`
AssigneeID string `json:"assignee_id"` AssigneeID string `json:"assignee_id"`
AssigneeName string `json:"assignee_name"` AssigneeName string `json:"assignee_name"`
AssigneeIDs []string `json:"assignee_ids"`
Assignees []BoardAssignableUser `json:"assignees"`
Sprint string `json:"sprint"` Sprint string `json:"sprint"`
Description string `json:"description"` Description string `json:"description"`
ChecklistTotal int `json:"checklist_total"`
ChecklistDone int `json:"checklist_done"`
AttachmentTotal int `json:"attachment_total"`
ImageTotal int `json:"image_total"`
} }
type BoardFieldValue struct { type BoardFieldValue struct {
@@ -0,0 +1,18 @@
CREATE TABLE block_checklist_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
block_id INTEGER NOT NULL,
title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
display_order INTEGER NOT NULL DEFAULT 0,
created_by INTEGER NOT NULL,
updated_by INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
delete_at INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (block_id) REFERENCES blocks(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id),
FOREIGN KEY (updated_by) REFERENCES users(id)
);
CREATE INDEX idx_block_checklist_items_block ON block_checklist_items(block_id, delete_at, display_order);
@@ -0,0 +1,16 @@
CREATE TABLE block_attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
block_id INTEGER NOT NULL,
filename TEXT NOT NULL,
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
size INTEGER NOT NULL DEFAULT 0,
storage_path TEXT NOT NULL,
created_by INTEGER NOT NULL,
created_at INTEGER NOT NULL,
delete_at INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (block_id) REFERENCES blocks(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id)
);
CREATE INDEX idx_block_attachments_block ON block_attachments(block_id, delete_at, created_at DESC);
@@ -0,0 +1,15 @@
CREATE TABLE block_assignees (
block_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
assigned_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (block_id, user_id),
FOREIGN KEY (block_id) REFERENCES blocks(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
INSERT OR IGNORE INTO block_assignees (block_id, user_id, assigned_at)
SELECT block_id, assignee_id, strftime('%s', 'now')
FROM block_properties
WHERE assignee_id IS NOT NULL;
CREATE INDEX idx_block_assignees_user ON block_assignees(user_id, block_id);
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE users ADD COLUMN avatar_storage_path TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN avatar_content_type TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN avatar_updated_at INTEGER NOT NULL DEFAULT 0;
+13
View File
@@ -661,6 +661,8 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
router.Handle("GET", "/api/auth/oidc/:id/callback", api.OIDCCallbackWithProvider) router.Handle("GET", "/api/auth/oidc/:id/callback", api.OIDCCallbackWithProvider)
router.Handle("GET", "/api/me", api.Me) router.Handle("GET", "/api/me", api.Me)
router.Handle("PATCH", "/api/me", api.UpdateMe) router.Handle("PATCH", "/api/me", api.UpdateMe)
router.Handle("POST", "/api/me/avatar", api.UploadMyAvatar)
router.Handle("DELETE", "/api/me/avatar", api.DeleteMyAvatar)
router.Handle("GET", "/api/me/totp", api.GetMyTOTP) router.Handle("GET", "/api/me/totp", api.GetMyTOTP)
router.Handle("POST", "/api/me/totp/setup", api.SetupMyTOTP) router.Handle("POST", "/api/me/totp/setup", api.SetupMyTOTP)
router.Handle("POST", "/api/me/totp/enable", api.EnableMyTOTP) router.Handle("POST", "/api/me/totp/enable", api.EnableMyTOTP)
@@ -668,6 +670,7 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
router.Handle("POST", "/api/me/totp/disable", api.DisableMyTOTP) router.Handle("POST", "/api/me/totp/disable", api.DisableMyTOTP)
router.Handle("GET", "/api/users", api.ListUsers) router.Handle("GET", "/api/users", api.ListUsers)
router.Handle("GET", "/api/users/:id/avatar", api.GetUserAvatar)
router.Handle("POST", "/api/users", api.CreateUser) router.Handle("POST", "/api/users", api.CreateUser)
router.Handle("PATCH", "/api/users/:id", api.UpdateUser) router.Handle("PATCH", "/api/users/:id", api.UpdateUser)
router.Handle("DELETE", "/api/users/:id", api.DeleteUser) router.Handle("DELETE", "/api/users/:id", api.DeleteUser)
@@ -875,9 +878,19 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
router.Handle("PUT", "/api/boards/:boardId/field-values/:field/:valueId", api.UpdateBoardFieldValue) router.Handle("PUT", "/api/boards/:boardId/field-values/:field/:valueId", api.UpdateBoardFieldValue)
router.Handle("DELETE", "/api/boards/:boardId/field-values/:field/:valueId", api.DeleteBoardFieldValue) router.Handle("DELETE", "/api/boards/:boardId/field-values/:field/:valueId", api.DeleteBoardFieldValue)
router.Handle("GET", "/api/boards/:boardId/block-properties", api.ListBoardBlockProperties) router.Handle("GET", "/api/boards/:boardId/block-properties", api.ListBoardBlockProperties)
router.Handle("GET", "/api/boards/:boardId/assignable-users", api.ListBoardAssignableUsers)
router.Handle("GET", "/api/boards/:boardId/blocks/:blockId/comments", api.ListBlockComments) router.Handle("GET", "/api/boards/:boardId/blocks/:blockId/comments", api.ListBlockComments)
router.Handle("POST", "/api/boards/:boardId/blocks/:blockId/comments", api.CreateBlockComment) router.Handle("POST", "/api/boards/:boardId/blocks/:blockId/comments", api.CreateBlockComment)
router.Handle("DELETE", "/api/boards/:boardId/blocks/:blockId/comments/:commentId", api.DeleteBlockComment) router.Handle("DELETE", "/api/boards/:boardId/blocks/:blockId/comments/:commentId", api.DeleteBlockComment)
router.Handle("GET", "/api/boards/:boardId/blocks/:blockId/checklist", api.ListBlockChecklistItems)
router.Handle("POST", "/api/boards/:boardId/blocks/:blockId/checklist", api.CreateBlockChecklistItem)
router.Handle("PUT", "/api/boards/:boardId/blocks/:blockId/checklist/reorder", api.ReorderBlockChecklistItems)
router.Handle("PUT", "/api/boards/:boardId/blocks/:blockId/checklist/:itemId", api.UpdateBlockChecklistItem)
router.Handle("DELETE", "/api/boards/:boardId/blocks/:blockId/checklist/:itemId", api.DeleteBlockChecklistItem)
router.Handle("GET", "/api/boards/:boardId/blocks/:blockId/attachments", api.ListBlockAttachments)
router.Handle("POST", "/api/boards/:boardId/blocks/:blockId/attachments", api.CreateBlockAttachment)
router.Handle("GET", "/api/boards/:boardId/blocks/:blockId/attachments/:attachmentId/download", api.DownloadBlockAttachment)
router.Handle("DELETE", "/api/boards/:boardId/blocks/:blockId/attachments/:attachmentId", api.DeleteBlockAttachment)
router.Handle("GET", "/api/boards/:boardId/blocks/:blockId/properties", api.GetBlockProperties) router.Handle("GET", "/api/boards/:boardId/blocks/:blockId/properties", api.GetBlockProperties)
router.Handle("PUT", "/api/boards/:boardId/blocks/:blockId/properties", api.UpsertBlockProperties) router.Handle("PUT", "/api/boards/:boardId/blocks/:blockId/properties", api.UpsertBlockProperties)
router.Handle("GET", "/api/boards/:boardId/members", api.ListBoardMembers) router.Handle("GET", "/api/boards/:boardId/members", api.ListBoardMembers)
+39
View File
@@ -48,6 +48,8 @@ erDiagram
boards ||--o{ blocks : contains boards ||--o{ blocks : contains
boards ||--o{ blocks_history : records boards ||--o{ blocks_history : records
boards ||--o{ board_members : shares boards ||--o{ board_members : shares
blocks ||--o{ block_checklist_items : tracks
blocks ||--o{ block_attachments : stores
users ||--o{ board_members : joins users ||--o{ board_members : joins
projects ||--o{ repos : owns projects ||--o{ repos : owns
projects ||--o{ project_repos : attaches projects ||--o{ project_repos : attaches
@@ -411,6 +413,43 @@ Indexes:
- `idx_blocks_history_block` on `(block_id, insert_at DESC)`. - `idx_blocks_history_block` on `(block_id, insert_at DESC)`.
### `block_checklist_items`
Stores lightweight checklist/subtask rows inside board cards.
| Column | Purpose |
| --- | --- |
| `public_id` | External checklist item ID. |
| `block_id` | Internal card/block FK. |
| `title` | Checklist item text. |
| `done` | Completion flag. |
| `display_order` | Item ordering within the card. |
| `created_by`, `updated_by` | Internal user FKs. |
| `created_at`, `updated_at`, `delete_at` | Lifecycle timestamps. |
Indexes:
- `idx_block_checklist_items_block` on `(block_id, delete_at, display_order)`.
### `block_attachments`
Stores card attachment metadata. File bytes are stored in the configured upload file store and referenced by `storage_path`.
| Column | Purpose |
| --- | --- |
| `public_id` | External attachment ID. |
| `block_id` | Internal card/block FK. |
| `filename` | Original uploaded filename. |
| `content_type` | MIME content type. |
| `size` | Uploaded byte size. |
| `storage_path` | Server-side storage path. |
| `created_by` | Internal user FK. |
| `created_at`, `delete_at` | Lifecycle timestamps. |
Indexes:
- `idx_block_attachments_block` on `(block_id, delete_at, created_at DESC)`.
### `board_members` ### `board_members`
Stores explicit per-board user sharing roles. Stores explicit per-board user sharing roles.
+414
View File
@@ -7780,6 +7780,351 @@ paths:
$ref: '#/components/responses/Forbidden' $ref: '#/components/responses/Forbidden'
'404': '404':
$ref: '#/components/responses/NotFound' $ref: '#/components/responses/NotFound'
/api/boards/{boardId}/blocks/{blockId}/checklist:
get:
tags:
- Boards
summary: List Block Checklist Items
operationId: ListBlockChecklistItems
x-codit-handler: ListBlockChecklistItems
parameters:
- name: boardId
in: path
required: true
schema:
type: string
- name: blockId
in: path
required: true
schema:
type: string
responses:
'200':
description: Successful response.
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/BlockChecklistItem'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
post:
tags:
- Boards
summary: Create Block Checklist Item
operationId: CreateBlockChecklistItem
x-codit-handler: CreateBlockChecklistItem
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateBlockChecklistItemRequest'
parameters:
- name: boardId
in: path
required: true
schema:
type: string
- name: blockId
in: path
required: true
schema:
type: string
responses:
'201':
description: Created successfully.
content:
application/json:
schema:
$ref: '#/components/schemas/BlockChecklistItem'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/api/boards/{boardId}/blocks/{blockId}/checklist/reorder:
put:
tags:
- Boards
summary: Reorder Block Checklist Items
operationId: ReorderBlockChecklistItems
x-codit-handler: ReorderBlockChecklistItems
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
ids:
type: array
items:
type: string
required:
- ids
parameters:
- name: boardId
in: path
required: true
schema:
type: string
- name: blockId
in: path
required: true
schema:
type: string
responses:
'200':
description: Successful response.
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/BlockChecklistItem'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/api/boards/{boardId}/blocks/{blockId}/checklist/{itemId}:
put:
tags:
- Boards
summary: Update Block Checklist Item
operationId: UpdateBlockChecklistItem
x-codit-handler: UpdateBlockChecklistItem
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateBlockChecklistItemRequest'
parameters:
- name: boardId
in: path
required: true
schema:
type: string
- name: blockId
in: path
required: true
schema:
type: string
- name: itemId
in: path
required: true
schema:
type: string
responses:
'200':
description: Successful response.
content:
application/json:
schema:
$ref: '#/components/schemas/BlockChecklistItem'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
delete:
tags:
- Boards
summary: Delete Block Checklist Item
operationId: DeleteBlockChecklistItem
x-codit-handler: DeleteBlockChecklistItem
parameters:
- name: boardId
in: path
required: true
schema:
type: string
- name: blockId
in: path
required: true
schema:
type: string
- name: itemId
in: path
required: true
schema:
type: string
responses:
'204':
description: Completed successfully.
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/api/boards/{boardId}/blocks/{blockId}/attachments:
get:
tags:
- Boards
summary: List Block Attachments
operationId: ListBlockAttachments
x-codit-handler: ListBlockAttachments
parameters:
- name: boardId
in: path
required: true
schema:
type: string
- name: blockId
in: path
required: true
schema:
type: string
responses:
'200':
description: Successful response.
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/BlockAttachment'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
post:
tags:
- Boards
summary: Create Block Attachment
operationId: CreateBlockAttachment
x-codit-handler: CreateBlockAttachment
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
properties:
file:
type: string
format: binary
required:
- file
parameters:
- name: boardId
in: path
required: true
schema:
type: string
- name: blockId
in: path
required: true
schema:
type: string
responses:
'201':
description: Created successfully.
content:
application/json:
schema:
$ref: '#/components/schemas/BlockAttachment'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/api/boards/{boardId}/blocks/{blockId}/attachments/{attachmentId}:
delete:
tags:
- Boards
summary: Delete Block Attachment
operationId: DeleteBlockAttachment
x-codit-handler: DeleteBlockAttachment
parameters:
- name: boardId
in: path
required: true
schema:
type: string
- name: blockId
in: path
required: true
schema:
type: string
- name: attachmentId
in: path
required: true
schema:
type: string
responses:
'204':
description: Completed successfully.
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/api/boards/{boardId}/blocks/{blockId}/attachments/{attachmentId}/download:
get:
tags:
- Boards
summary: Download Block Attachment
operationId: DownloadBlockAttachment
x-codit-handler: DownloadBlockAttachment
parameters:
- name: boardId
in: path
required: true
schema:
type: string
- name: blockId
in: path
required: true
schema:
type: string
- name: attachmentId
in: path
required: true
schema:
type: string
- name: inline
in: query
required: false
schema:
type: string
responses:
'200':
description: Attachment file stream.
content:
application/octet-stream:
schema:
type: string
format: binary
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/api/boards/{boardId}/members: /api/boards/{boardId}/members:
get: get:
tags: tags:
@@ -10891,6 +11236,75 @@ components:
required: required:
- id - id
additionalProperties: true additionalProperties: true
BlockChecklistItem:
type: object
properties:
id:
type: string
block_id:
type: string
title:
type: string
done:
type: boolean
display_order:
type: integer
created_by:
type: string
updated_by:
type: string
created_at:
type: integer
format: int64
updated_at:
type: integer
format: int64
delete_at:
type: integer
format: int64
additionalProperties: true
CreateBlockChecklistItemRequest:
type: object
properties:
title:
type: string
required:
- title
additionalProperties: true
UpdateBlockChecklistItemRequest:
type: object
properties:
title:
type: string
done:
type: boolean
required:
- title
- done
additionalProperties: true
BlockAttachment:
type: object
properties:
id:
type: string
block_id:
type: string
filename:
type: string
content_type:
type: string
size:
type: integer
format: int64
created_by:
type: string
created_at:
type: integer
format: int64
delete_at:
type: integer
format: int64
additionalProperties: true
BoardMember: BoardMember:
type: object type: object
properties: properties:
+66 -1
View File
@@ -10,6 +10,8 @@ export interface User {
totp_effective_required?: boolean totp_effective_required?: boolean
totp_setup_required?: boolean totp_setup_required?: boolean
totp_verify_required?: boolean totp_verify_required?: boolean
avatar_url?: string
avatar_updated_at?: number
disabled?: boolean disabled?: boolean
permissions?: string[] permissions?: string[]
} }
@@ -1438,6 +1440,13 @@ export interface BoardMember {
created_at: number created_at: number
} }
export interface BoardAssignableUser {
id: string
username: string
display_name: string
avatar_url: string
}
export type BoardMemberPayload = { export type BoardMemberPayload = {
user_id: string user_id: string
role: string role: string
@@ -1454,6 +1463,31 @@ export interface BlockComment {
can_delete?: boolean can_delete?: boolean
} }
export interface BlockChecklistItem {
id: string
block_id: string
title: string
done: boolean
display_order: number
created_by: string
updated_by: string
created_at: number
updated_at: number
delete_at?: number
}
export interface BlockAttachment {
id: string
block_id: string
filename: string
content_type: string
size: number
storage_path?: string
created_by: string
created_at: number
delete_at?: number
}
export interface BlockProperties { export interface BlockProperties {
block_id: string block_id: string
status: string status: string
@@ -1462,11 +1496,17 @@ export interface BlockProperties {
due_date: string due_date: string
assignee_id: string assignee_id: string
assignee_name: string assignee_name: string
assignee_ids: string[]
assignees: BoardAssignableUser[]
sprint: string sprint: string
description: string description: string
checklist_total: number
checklist_done: number
attachment_total: number
image_total: number
} }
export type BlockPropertiesPayload = Omit<BlockProperties, 'block_id' | 'assignee_name'> export type BlockPropertiesPayload = Omit<BlockProperties, 'block_id' | 'assignee_id' | 'assignee_name' | 'assignees' | 'checklist_total' | 'checklist_done' | 'attachment_total' | 'image_total'>
export interface BoardFieldValue { export interface BoardFieldValue {
id: string id: string
@@ -1503,6 +1543,12 @@ export const api = {
me: () => request<User>('/api/me'), me: () => request<User>('/api/me'),
updateMe: (payload: MeUpdatePayload) => updateMe: (payload: MeUpdatePayload) =>
request<User>('/api/me', { method: 'PATCH', body: JSON.stringify(payload) }), request<User>('/api/me', { method: 'PATCH', body: JSON.stringify(payload) }),
uploadMyAvatar: (file: File) => {
const form: FormData = new FormData()
form.append('avatar', file)
return requestForm<User>('/api/me/avatar', { method: 'POST', body: form })
},
deleteMyAvatar: () => request<User>('/api/me/avatar', { method: 'DELETE' }),
getMyTOTP: () => request<TOTPStatus>('/api/me/totp'), getMyTOTP: () => request<TOTPStatus>('/api/me/totp'),
setupMyTOTP: () => request<TOTPSetupResponse>('/api/me/totp/setup', { method: 'POST' }), setupMyTOTP: () => request<TOTPSetupResponse>('/api/me/totp/setup', { method: 'POST' }),
enableMyTOTP: (otpCode: string) => enableMyTOTP: (otpCode: string) =>
@@ -2155,6 +2201,7 @@ export const api = {
deleteBlock: (boardId: string, blockId: string) => deleteBlock: (boardId: string, blockId: string) =>
request<void>(`/api/boards/${boardId}/blocks/${blockId}`, { method: 'DELETE' }), request<void>(`/api/boards/${boardId}/blocks/${blockId}`, { method: 'DELETE' }),
listBoardAssignableUsers: (boardId: string) => request<BoardAssignableUser[]>(`/api/boards/${boardId}/assignable-users`),
listBoardMembers: (boardId: string) => request<BoardMember[]>(`/api/boards/${boardId}/members`), listBoardMembers: (boardId: string) => request<BoardMember[]>(`/api/boards/${boardId}/members`),
addBoardMember: (boardId: string, payload: BoardMemberPayload) => addBoardMember: (boardId: string, payload: BoardMemberPayload) =>
request<void>(`/api/boards/${boardId}/members`, { method: 'POST', body: JSON.stringify(payload) }), request<void>(`/api/boards/${boardId}/members`, { method: 'POST', body: JSON.stringify(payload) }),
@@ -2182,6 +2229,24 @@ export const api = {
request<BlockComment>(`/api/boards/${boardId}/blocks/${blockId}/comments`, { method: 'POST', body: JSON.stringify({ content }) }), request<BlockComment>(`/api/boards/${boardId}/blocks/${blockId}/comments`, { method: 'POST', body: JSON.stringify({ content }) }),
deleteBlockComment: (boardId: string, blockId: string, commentId: string) => deleteBlockComment: (boardId: string, blockId: string, commentId: string) =>
request<void>(`/api/boards/${boardId}/blocks/${blockId}/comments/${commentId}`, { method: 'DELETE' }), request<void>(`/api/boards/${boardId}/blocks/${blockId}/comments/${commentId}`, { method: 'DELETE' }),
listBlockChecklistItems: (boardId: string, blockId: string) =>
request<BlockChecklistItem[]>(`/api/boards/${boardId}/blocks/${blockId}/checklist`),
createBlockChecklistItem: (boardId: string, blockId: string, title: string) =>
request<BlockChecklistItem>(`/api/boards/${boardId}/blocks/${blockId}/checklist`, { method: 'POST', body: JSON.stringify({ title }) }),
updateBlockChecklistItem: (boardId: string, blockId: string, itemId: string, payload: { title: string; done: boolean }) =>
request<BlockChecklistItem>(`/api/boards/${boardId}/blocks/${blockId}/checklist/${itemId}`, { method: 'PUT', body: JSON.stringify(payload) }),
reorderBlockChecklistItems: (boardId: string, blockId: string, ids: string[]) =>
request<BlockChecklistItem[]>(`/api/boards/${boardId}/blocks/${blockId}/checklist/reorder`, { method: 'PUT', body: JSON.stringify({ ids }) }),
deleteBlockChecklistItem: (boardId: string, blockId: string, itemId: string) =>
request<void>(`/api/boards/${boardId}/blocks/${blockId}/checklist/${itemId}`, { method: 'DELETE' }),
listBlockAttachments: (boardId: string, blockId: string) =>
request<BlockAttachment[]>(`/api/boards/${boardId}/blocks/${blockId}/attachments`),
createBlockAttachment: (boardId: string, blockId: string, form: FormData) =>
requestForm<BlockAttachment>(`/api/boards/${boardId}/blocks/${blockId}/attachments`, { method: 'POST', body: form }),
downloadBlockAttachment: (boardId: string, blockId: string, attachmentId: string, fallbackFilename: string) =>
requestBinaryDownload(`/api/boards/${boardId}/blocks/${blockId}/attachments/${attachmentId}/download`, fallbackFilename),
deleteBlockAttachment: (boardId: string, blockId: string, attachmentId: string) =>
request<void>(`/api/boards/${boardId}/blocks/${blockId}/attachments/${attachmentId}`, { method: 'DELETE' }),
getBlockProperties: (boardId: string, blockId: string) => getBlockProperties: (boardId: string, blockId: string) =>
request<BlockProperties>(`/api/boards/${boardId}/blocks/${blockId}/properties`), request<BlockProperties>(`/api/boards/${boardId}/blocks/${blockId}/properties`),
upsertBlockProperties: (boardId: string, blockId: string, props: BlockPropertiesPayload) => upsertBlockProperties: (boardId: string, blockId: string, props: BlockPropertiesPayload) =>
@@ -0,0 +1,72 @@
import { Avatar, AvatarGroup, Box, Tooltip } from '@mui/material'
import { BoardAssignableUser } from '../api'
type BoardAssigneeStackProps = {
assignees: BoardAssignableUser[]
currentUserId?: string
max?: number
size?: number
}
function assigneeLabel(user: BoardAssignableUser): string {
return user.display_name ? `${user.display_name} (${user.username})` : user.username
}
function assigneeInitials(user: BoardAssignableUser): string {
const source: string = user.display_name || user.username || user.id
const parts: string[] = source.trim().split(/\s+/)
const first: string = parts[0]?.[0] || ''
const second: string = parts.length > 1 ? (parts[1]?.[0] || '') : (parts[0]?.[1] || '')
return `${first}${second}`.toUpperCase()
}
export function formatAssigneeList(assignees: BoardAssignableUser[]): string {
if (!assignees.length) return ''
return assignees.map((user: BoardAssignableUser) => assigneeLabel(user)).join(', ')
}
export default function BoardAssigneeStack(props: BoardAssigneeStackProps) {
const max: number = props.max || 3
const size: number = props.size || 22
const title: string = formatAssigneeList(props.assignees)
if (!props.assignees.length) {
return null
}
return (
<Tooltip title={title} arrow>
<Box sx={{ display: 'inline-flex', alignItems: 'center' }}>
<AvatarGroup
max={max}
sx={{
'& .MuiAvatar-root': {
width: size,
height: size,
fontSize: size <= 20 ? 10 : 11,
borderWidth: 1,
},
}}
>
{props.assignees.map((user: BoardAssignableUser) => {
const assignedToMe: boolean = user.id === props.currentUserId
return (
<Avatar
key={user.id}
alt={assigneeLabel(user)}
src={user.avatar_url || undefined}
sx={{
bgcolor: assignedToMe ? 'primary.main' : 'action.selected',
color: assignedToMe ? 'primary.contrastText' : 'text.primary',
boxShadow: assignedToMe ? '0 0 0 2px rgba(25, 118, 210, 0.45)' : 'none',
}}
>
{assigneeInitials(user)}
</Avatar>
)
})}
</AvatarGroup>
</Box>
</Tooltip>
)
}
+170 -11
View File
@@ -1,6 +1,10 @@
import AddIcon from '@mui/icons-material/Add' import AddIcon from '@mui/icons-material/Add'
import AttachFileIcon from '@mui/icons-material/AttachFile'
import CheckCircleIcon from '@mui/icons-material/CheckCircle' import CheckCircleIcon from '@mui/icons-material/CheckCircle'
import ChecklistIcon from '@mui/icons-material/Checklist'
import DescriptionIcon from '@mui/icons-material/Description'
import DragIndicatorIcon from '@mui/icons-material/DragIndicator' import DragIndicatorIcon from '@mui/icons-material/DragIndicator'
import ImageIcon from '@mui/icons-material/Image'
import { import {
DndContext, DndContext,
DragEndEvent, DragEndEvent,
@@ -20,10 +24,11 @@ import {
verticalListSortingStrategy, verticalListSortingStrategy,
} from '@dnd-kit/sortable' } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities' import { CSS } from '@dnd-kit/utilities'
import { Box, Button, Chip, Paper, Typography } from '@mui/material' import { Box, Button, Chip, Paper, Tooltip, Typography } from '@mui/material'
import { Theme } from '@mui/material/styles' import { Theme } from '@mui/material/styles'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { api, Block, BlockProperties, BlockPropertiesPayload, BoardFieldValue, GroupBy } from '../api' import { api, Block, BlockProperties, BlockPropertiesPayload, BoardFieldValue, GroupBy } from '../api'
import BoardAssigneeStack from './BoardAssigneeStack'
export type ColDef = { value: string; label: string; dotColor: string } export type ColDef = { value: string; label: string; dotColor: string }
@@ -81,7 +86,7 @@ function buildPropsPayload(
priority: groupBy === 'priority' ? targetColValue : (existing?.priority ?? ''), priority: groupBy === 'priority' ? targetColValue : (existing?.priority ?? ''),
sprint: groupBy === 'sprint' ? targetColValue : (existing?.sprint ?? ''), sprint: groupBy === 'sprint' ? targetColValue : (existing?.sprint ?? ''),
due_date: existing?.due_date ?? '', due_date: existing?.due_date ?? '',
assignee_id: existing?.assignee_id ?? '', assignee_ids: existing?.assignee_ids ?? [],
description: existing?.description ?? '', description: existing?.description ?? '',
} }
} }
@@ -112,6 +117,20 @@ function cloneOrder(order: Record<string, string[]>): Record<string, string[]> {
return next return next
} }
function descriptionPreview(value: string): string {
const text: string = value
.replace(/```[\s\S]*?```/g, ' ')
.replace(/`([^`]+)`/g, '$1')
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/[#>*_~\\-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (text.length > 260) return `${text.slice(0, 257).trim()}...`
return text
}
const COL_PREFIX = 'col:' const COL_PREFIX = 'col:'
// --- Card components -------------------------------------------------------- // --- Card components --------------------------------------------------------
@@ -121,16 +140,25 @@ type SortableCardProps = {
groupBy: GroupBy groupBy: GroupBy
propsByBlockId: Record<string, BlockProperties> propsByBlockId: Record<string, BlockProperties>
fieldValues: Record<string, BoardFieldValue[]> fieldValues: Record<string, BoardFieldValue[]>
currentUserId?: string
onCardClick: (card: Block) => void onCardClick: (card: Block) => void
} }
function SortableCard({ card, groupBy, propsByBlockId, fieldValues, onCardClick }: SortableCardProps) { function SortableCard({ card, groupBy, propsByBlockId, fieldValues, currentUserId, onCardClick }: SortableCardProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: card.id, id: card.id,
data: { type: 'card' }, data: { type: 'card' },
}) })
const chips = getCardChips(card, groupBy, propsByBlockId, fieldValues) const chips = getCardChips(card, groupBy, propsByBlockId, fieldValues)
const completed: boolean = (card.completed_at || 0) > 0 const completed: boolean = (card.completed_at || 0) > 0
const cardProps: BlockProperties | undefined = propsByBlockId[card.id]
const checklistTotal: number = cardProps?.checklist_total || 0
const checklistDone: number = cardProps?.checklist_done || 0
const checklistComplete: boolean = checklistTotal > 0 && checklistDone >= checklistTotal
const descriptionText: string = descriptionPreview(cardProps?.description || '')
const attachmentTotal: number = cardProps?.attachment_total || 0
const imageTotal: number = cardProps?.image_total || 0
const assignedToMe: boolean = Boolean(currentUserId && (cardProps?.assignees || []).some((user) => user.id === currentUserId))
return ( return (
<Paper <Paper
@@ -141,7 +169,8 @@ function SortableCard({ card, groupBy, propsByBlockId, fieldValues, onCardClick
p: 1.5, p: 1.5,
cursor: 'pointer', cursor: 'pointer',
border: '1px solid', border: '1px solid',
borderColor: 'divider', borderColor: assignedToMe ? 'primary.main' : 'divider',
borderLeftWidth: assignedToMe ? 3 : 1,
opacity: isDragging ? 0 : (completed ? 0.68 : 1), opacity: isDragging ? 0 : (completed ? 0.68 : 1),
'&:hover': { borderColor: 'primary.main', bgcolor: 'action.hover' }, '&:hover': { borderColor: 'primary.main', bgcolor: 'action.hover' },
}} }}
@@ -166,8 +195,8 @@ function SortableCard({ card, groupBy, propsByBlockId, fieldValues, onCardClick
</Typography> </Typography>
{completed ? <CheckCircleIcon color="success" sx={{ fontSize: 15, flexShrink: 0 }} /> : null} {completed ? <CheckCircleIcon color="success" sx={{ fontSize: 15, flexShrink: 0 }} /> : null}
</Box> </Box>
{chips.length > 0 ? ( {chips.length > 0 || descriptionText || checklistTotal > 0 || attachmentTotal > 0 || (cardProps?.assignees || []).length > 0 ? (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 0.5, mt: 1 }}>
{chips.map((chip) => ( {chips.map((chip) => (
<Box <Box
key={chip.field} key={chip.field}
@@ -186,6 +215,64 @@ function SortableCard({ card, groupBy, propsByBlockId, fieldValues, onCardClick
</Typography> </Typography>
</Box> </Box>
))} ))}
{descriptionText ? (
<Tooltip title={descriptionText} arrow>
<Box
sx={{
display: 'flex', alignItems: 'center',
px: 0.75, py: 0.25,
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
bgcolor: 'background.default',
color: 'text.secondary',
}}
>
<DescriptionIcon sx={{ fontSize: 13 }} />
</Box>
</Tooltip>
) : null}
{checklistTotal > 0 ? (
<Box
sx={{
display: 'flex', alignItems: 'center', gap: 0.4,
px: 0.75, py: 0.25,
border: '1px solid',
borderColor: checklistComplete ? 'success.main' : 'divider',
borderRadius: 1,
bgcolor: checklistComplete ? 'success.light' : 'background.default',
color: checklistComplete ? 'success.contrastText' : 'text.secondary',
}}
>
<ChecklistIcon sx={{ fontSize: 13 }} />
<Typography variant="caption" sx={{ fontSize: 11, lineHeight: 1.4, color: 'inherit' }}>
{checklistDone}/{checklistTotal}
</Typography>
</Box>
) : null}
{attachmentTotal > 0 ? (
<Box
sx={{
display: 'flex', alignItems: 'center', gap: 0.4,
px: 0.75, py: 0.25,
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
bgcolor: 'background.default',
color: imageTotal > 0 ? 'primary.main' : 'text.secondary',
}}
>
{imageTotal > 0 ? <ImageIcon sx={{ fontSize: 13 }} /> : <AttachFileIcon sx={{ fontSize: 13 }} />}
<Typography variant="caption" sx={{ fontSize: 11, lineHeight: 1.4, color: 'inherit' }}>
{attachmentTotal}
</Typography>
</Box>
) : null}
{(cardProps?.assignees || []).length > 0 ? (
<Box sx={{ ml: 'auto', pl: 0.5 }}>
<BoardAssigneeStack assignees={cardProps?.assignees || []} currentUserId={currentUserId} max={3} size={22} />
</Box>
) : null}
</Box> </Box>
) : null} ) : null}
</Box> </Box>
@@ -194,11 +281,19 @@ function SortableCard({ card, groupBy, propsByBlockId, fieldValues, onCardClick
) )
} }
function CardOverlay({ card, groupBy, propsByBlockId, fieldValues }: Omit<SortableCardProps, 'onCardClick'>) { function CardOverlay({ card, groupBy, propsByBlockId, fieldValues, currentUserId }: Omit<SortableCardProps, 'onCardClick'>) {
const chips = getCardChips(card, groupBy, propsByBlockId, fieldValues) const chips = getCardChips(card, groupBy, propsByBlockId, fieldValues)
const completed: boolean = (card.completed_at || 0) > 0 const completed: boolean = (card.completed_at || 0) > 0
const cardProps: BlockProperties | undefined = propsByBlockId[card.id]
const checklistTotal: number = cardProps?.checklist_total || 0
const checklistDone: number = cardProps?.checklist_done || 0
const checklistComplete: boolean = checklistTotal > 0 && checklistDone >= checklistTotal
const descriptionText: string = descriptionPreview(cardProps?.description || '')
const attachmentTotal: number = cardProps?.attachment_total || 0
const imageTotal: number = cardProps?.image_total || 0
const assignedToMe: boolean = Boolean(currentUserId && (cardProps?.assignees || []).some((user) => user.id === currentUserId))
return ( return (
<Paper sx={{ p: 1.5, border: '1px solid', borderColor: 'primary.main', boxShadow: 6, cursor: 'grabbing' }}> <Paper sx={{ p: 1.5, border: '1px solid', borderLeftWidth: assignedToMe ? 3 : 1, borderColor: 'primary.main', boxShadow: 6, cursor: 'grabbing' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5 }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5 }}>
<Box sx={{ color: 'text.secondary', display: 'flex', alignItems: 'center', flexShrink: 0, mt: 0.25 }}> <Box sx={{ color: 'text.secondary', display: 'flex', alignItems: 'center', flexShrink: 0, mt: 0.25 }}>
<DragIndicatorIcon sx={{ fontSize: 16 }} /> <DragIndicatorIcon sx={{ fontSize: 16 }} />
@@ -210,8 +305,8 @@ function CardOverlay({ card, groupBy, propsByBlockId, fieldValues }: Omit<Sortab
</Typography> </Typography>
{completed ? <CheckCircleIcon color="success" sx={{ fontSize: 15, flexShrink: 0 }} /> : null} {completed ? <CheckCircleIcon color="success" sx={{ fontSize: 15, flexShrink: 0 }} /> : null}
</Box> </Box>
{chips.length > 0 ? ( {chips.length > 0 || descriptionText || checklistTotal > 0 || attachmentTotal > 0 || (cardProps?.assignees || []).length > 0 ? (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 0.5, mt: 1 }}>
{chips.map((chip) => ( {chips.map((chip) => (
<Box <Box
key={chip.field} key={chip.field}
@@ -230,6 +325,64 @@ function CardOverlay({ card, groupBy, propsByBlockId, fieldValues }: Omit<Sortab
</Typography> </Typography>
</Box> </Box>
))} ))}
{descriptionText ? (
<Tooltip title={descriptionText} arrow>
<Box
sx={{
display: 'flex', alignItems: 'center',
px: 0.75, py: 0.25,
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
bgcolor: 'background.default',
color: 'text.secondary',
}}
>
<DescriptionIcon sx={{ fontSize: 13 }} />
</Box>
</Tooltip>
) : null}
{checklistTotal > 0 ? (
<Box
sx={{
display: 'flex', alignItems: 'center', gap: 0.4,
px: 0.75, py: 0.25,
border: '1px solid',
borderColor: checklistComplete ? 'success.main' : 'divider',
borderRadius: 1,
bgcolor: checklistComplete ? 'success.light' : 'background.default',
color: checklistComplete ? 'success.contrastText' : 'text.secondary',
}}
>
<ChecklistIcon sx={{ fontSize: 13 }} />
<Typography variant="caption" sx={{ fontSize: 11, lineHeight: 1.4, color: 'inherit' }}>
{checklistDone}/{checklistTotal}
</Typography>
</Box>
) : null}
{attachmentTotal > 0 ? (
<Box
sx={{
display: 'flex', alignItems: 'center', gap: 0.4,
px: 0.75, py: 0.25,
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
bgcolor: 'background.default',
color: imageTotal > 0 ? 'primary.main' : 'text.secondary',
}}
>
{imageTotal > 0 ? <ImageIcon sx={{ fontSize: 13 }} /> : <AttachFileIcon sx={{ fontSize: 13 }} />}
<Typography variant="caption" sx={{ fontSize: 11, lineHeight: 1.4, color: 'inherit' }}>
{attachmentTotal}
</Typography>
</Box>
) : null}
{(cardProps?.assignees || []).length > 0 ? (
<Box sx={{ ml: 'auto', pl: 0.5 }}>
<BoardAssigneeStack assignees={cardProps?.assignees || []} currentUserId={currentUserId} max={3} size={22} />
</Box>
) : null}
</Box> </Box>
) : null} ) : null}
</Box> </Box>
@@ -269,6 +422,7 @@ type KanbanColumnProps = {
groupBy: GroupBy groupBy: GroupBy
propsByBlockId: Record<string, BlockProperties> propsByBlockId: Record<string, BlockProperties>
fieldValues: Record<string, BoardFieldValue[]> fieldValues: Record<string, BoardFieldValue[]>
currentUserId?: string
newCardTitle: string newCardTitle: string
addingCard: boolean addingCard: boolean
onStartAddCard: (colValue: string) => void onStartAddCard: (colValue: string) => void
@@ -280,7 +434,7 @@ type KanbanColumnProps = {
function KanbanColumn({ function KanbanColumn({
col, cards, cardIds, isAdding, draggable, col, cards, cardIds, isAdding, draggable,
groupBy, propsByBlockId, fieldValues, groupBy, propsByBlockId, fieldValues, currentUserId,
newCardTitle, addingCard, newCardTitle, addingCard,
onStartAddCard, onCancelAddCard, onCommitAddCard, onNewCardTitleChange, onCardClick, onStartAddCard, onCancelAddCard, onCommitAddCard, onNewCardTitleChange, onCardClick,
}: KanbanColumnProps) { }: KanbanColumnProps) {
@@ -336,6 +490,7 @@ function KanbanColumn({
groupBy={groupBy} groupBy={groupBy}
propsByBlockId={propsByBlockId} propsByBlockId={propsByBlockId}
fieldValues={fieldValues} fieldValues={fieldValues}
currentUserId={currentUserId}
onCardClick={onCardClick} onCardClick={onCardClick}
/> />
))} ))}
@@ -395,6 +550,7 @@ type Props = {
propsByBlockId: Record<string, BlockProperties> propsByBlockId: Record<string, BlockProperties>
fieldValues: Record<string, BoardFieldValue[]> fieldValues: Record<string, BoardFieldValue[]>
groupBy: GroupBy groupBy: GroupBy
currentUserId?: string
addingInCol: string | null addingInCol: string | null
newCardTitle: string newCardTitle: string
addingCard: boolean addingCard: boolean
@@ -414,6 +570,7 @@ export default function BoardKanbanView({
propsByBlockId, propsByBlockId,
fieldValues, fieldValues,
groupBy, groupBy,
currentUserId,
addingInCol, addingInCol,
newCardTitle, newCardTitle,
addingCard, addingCard,
@@ -668,6 +825,7 @@ export default function BoardKanbanView({
groupBy={groupBy} groupBy={groupBy}
propsByBlockId={propsByBlockId} propsByBlockId={propsByBlockId}
fieldValues={fieldValues} fieldValues={fieldValues}
currentUserId={currentUserId}
newCardTitle={newCardTitle} newCardTitle={newCardTitle}
addingCard={addingCard} addingCard={addingCard}
onStartAddCard={onStartAddCard} onStartAddCard={onStartAddCard}
@@ -689,6 +847,7 @@ export default function BoardKanbanView({
groupBy={groupBy} groupBy={groupBy}
propsByBlockId={propsByBlockId} propsByBlockId={propsByBlockId}
fieldValues={fieldValues} fieldValues={fieldValues}
currentUserId={currentUserId}
/> />
</Box> </Box>
) : activeColDef ? ( ) : activeColDef ? (
+76 -10
View File
@@ -1,7 +1,11 @@
import AddIcon from '@mui/icons-material/Add' import AddIcon from '@mui/icons-material/Add'
import AttachFileIcon from '@mui/icons-material/AttachFile'
import CheckCircleIcon from '@mui/icons-material/CheckCircle' import CheckCircleIcon from '@mui/icons-material/CheckCircle'
import ChecklistIcon from '@mui/icons-material/Checklist'
import DescriptionIcon from '@mui/icons-material/Description'
import ExpandLessIcon from '@mui/icons-material/ExpandLess' import ExpandLessIcon from '@mui/icons-material/ExpandLess'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore' import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import ImageIcon from '@mui/icons-material/Image'
import { import {
Box, Box,
Button, Button,
@@ -11,10 +15,12 @@ import {
TableCell, TableCell,
TableRow, TableRow,
TextField, TextField,
Tooltip,
Typography, Typography,
} from '@mui/material' } from '@mui/material'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { Block, BlockProperties, BoardFieldValue, GroupBy } from '../api' import { Block, BlockProperties, BoardFieldValue, GroupBy } from '../api'
import BoardAssigneeStack from './BoardAssigneeStack'
import ResizableSortableTable, { RSTColumn } from './ResizableSortableTable' import ResizableSortableTable, { RSTColumn } from './ResizableSortableTable'
type BoardTableViewProps = { type BoardTableViewProps = {
@@ -22,6 +28,7 @@ type BoardTableViewProps = {
properties: BlockProperties[] properties: BlockProperties[]
fieldValues: Record<string, BoardFieldValue[]> fieldValues: Record<string, BoardFieldValue[]>
groupBy: GroupBy groupBy: GroupBy
currentUserId?: string
onCardClick: (block: Block) => void onCardClick: (block: Block) => void
onAddCard: (groupValue: string, title: string) => void onAddCard: (groupValue: string, title: string) => void
} }
@@ -72,10 +79,25 @@ function resolveColor(key: string, values: BoardFieldValue[]): string {
return values.find((v) => v.value === key)?.color ?? '' return values.find((v) => v.value === key)?.color ?? ''
} }
function descriptionPreview(value: string): string {
const text: string = value
.replace(/```[\s\S]*?```/g, ' ')
.replace(/`([^`]+)`/g, '$1')
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/[#>*_~\\-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (text.length > 260) return `${text.slice(0, 257).trim()}...`
return text
}
function buildColumns( function buildColumns(
typeValues: BoardFieldValue[], typeValues: BoardFieldValue[],
statusValues: BoardFieldValue[], statusValues: BoardFieldValue[],
priorityValues: BoardFieldValue[], priorityValues: BoardFieldValue[],
currentUserId?: string,
): RSTColumn<RowItem>[] { ): RSTColumn<RowItem>[] {
return [ return [
{ {
@@ -159,28 +181,71 @@ function buildColumns(
</Typography> </Typography>
), ),
}, },
{
key: 'details',
label: 'Details',
defaultWidth: 150,
minWidth: 80,
getValue: (r) => `${r.props.description} ${r.props.checklist_done}/${r.props.checklist_total} ${r.props.attachment_total}`,
renderCell: (r) => {
const text: string = descriptionPreview(r.props.description || '')
const checklistComplete: boolean = r.props.checklist_total > 0 && r.props.checklist_done >= r.props.checklist_total
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
{text ? (
<Tooltip title={text} arrow>
<Box sx={{ display: 'inline-flex', color: 'text.secondary' }}>
<DescriptionIcon sx={{ fontSize: 16 }} />
</Box>
</Tooltip>
) : null}
{r.props.checklist_total > 0 ? (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.25, color: checklistComplete ? 'success.main' : 'text.secondary' }}>
<ChecklistIcon sx={{ fontSize: 16 }} />
<Typography variant="caption" color="inherit">
{r.props.checklist_done}/{r.props.checklist_total}
</Typography>
</Box>
) : null}
{r.props.attachment_total > 0 ? (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.25, color: r.props.image_total > 0 ? 'primary.main' : 'text.secondary' }}>
{r.props.image_total > 0 ? <ImageIcon sx={{ fontSize: 16 }} /> : <AttachFileIcon sx={{ fontSize: 16 }} />}
<Typography variant="caption" color="inherit">
{r.props.attachment_total}
</Typography>
</Box>
) : null}
{!text && r.props.checklist_total === 0 && r.props.attachment_total === 0 ? (
<Typography variant="caption" color="text.disabled">-</Typography>
) : null}
</Box>
)
},
},
{ {
key: 'assignee', key: 'assignee',
label: 'Assignee', label: 'Assignees',
defaultWidth: 120, defaultWidth: 120,
minWidth: 60, minWidth: 60,
getValue: (r) => r.props.assignee_name || r.props.assignee_id, getValue: (r) => (r.props.assignees || []).map((user) => user.display_name || user.username).join(', '),
renderCell: (r) => ( renderCell: (r) => (
<Typography variant="body2" color="text.secondary"> (r.props.assignees || []).length > 0 ? (
{r.props.assignee_name || r.props.assignee_id || '-'} <BoardAssigneeStack assignees={r.props.assignees || []} currentUserId={currentUserId} max={4} size={22} />
</Typography> ) : (
<Typography variant="caption" color="text.disabled">-</Typography>
)
), ),
}, },
] ]
} }
export default function BoardTableView(props: BoardTableViewProps) { export default function BoardTableView(props: BoardTableViewProps) {
const { blocks, properties, fieldValues, groupBy, onCardClick, onAddCard } = props const { blocks, properties, fieldValues, groupBy, currentUserId, onCardClick, onAddCard } = props
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({}) const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
const [addingInGroup, setAddingInGroup] = useState<string | null>(null) const [addingInGroup, setAddingInGroup] = useState<string | null>(null)
const [addTitle, setAddTitle] = useState('') const [addTitle, setAddTitle] = useState('')
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({ const [columnWidths, setColumnWidths] = useState<Record<string, number>>({
title: 280, type: 110, status: 120, priority: 110, due_date: 110, assignee: 120, title: 280, type: 110, status: 120, priority: 110, due_date: 110, details: 150, assignee: 120,
}) })
const activeValues = fieldValues[groupBy] ?? [] const activeValues = fieldValues[groupBy] ?? []
@@ -194,9 +259,9 @@ export default function BoardTableView(props: BoardTableViewProps) {
const priorityValues = fieldValues['priority'] ?? [] const priorityValues = fieldValues['priority'] ?? []
const columns = useMemo( const columns = useMemo(
() => buildColumns(typeValues, statusValues, priorityValues), () => buildColumns(typeValues, statusValues, priorityValues, currentUserId),
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[fieldValues], [fieldValues, currentUserId],
) )
const propsByBlockId = properties.reduce<Record<string, BlockProperties>>((acc, p) => { const propsByBlockId = properties.reduce<Record<string, BlockProperties>>((acc, p) => {
@@ -210,7 +275,8 @@ export default function BoardTableView(props: BoardTableViewProps) {
const groupMap = blocks.reduce<Record<string, RowGroup>>((acc, block) => { const groupMap = blocks.reduce<Record<string, RowGroup>>((acc, block) => {
const p = propsByBlockId[block.id] ?? { const p = propsByBlockId[block.id] ?? {
block_id: block.id, status: '', card_type: '', priority: '', block_id: block.id, status: '', card_type: '', priority: '',
due_date: '', assignee_id: '', assignee_name: '', sprint: '', description: '', due_date: '', assignee_id: '', assignee_name: '', assignee_ids: [], assignees: [], sprint: '', description: '',
checklist_total: 0, checklist_done: 0, attachment_total: 0, image_total: 0,
} }
const key = getGroupKey(p, groupBy) const key = getGroupKey(p, groupBy)
if (!acc[key]) acc[key] = { key, rows: [] } if (!acc[key]) acc[key] = { key, rows: [] }
+493 -20
View File
@@ -1,12 +1,32 @@
import AttachFileIcon from '@mui/icons-material/AttachFile'
import CheckIcon from '@mui/icons-material/Check' import CheckIcon from '@mui/icons-material/Check'
import CheckCircleIcon from '@mui/icons-material/CheckCircle' import CheckCircleIcon from '@mui/icons-material/CheckCircle'
import CloseIcon from '@mui/icons-material/Close' import CloseIcon from '@mui/icons-material/Close'
import DeleteIcon from '@mui/icons-material/Delete'
import DownloadIcon from '@mui/icons-material/Download'
import DragIndicatorIcon from '@mui/icons-material/DragIndicator'
import ImageIcon from '@mui/icons-material/Image'
import SendIcon from '@mui/icons-material/Send' import SendIcon from '@mui/icons-material/Send'
import {
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
SortableContext,
arrayMove,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { import {
Alert, Alert,
Avatar, Avatar,
Box, Box,
Button, Button,
Checkbox,
Chip, Chip,
CircularProgress, CircularProgress,
DialogActions, DialogActions,
@@ -24,11 +44,12 @@ import {
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import { api, Block, BlockComment, BlockProperties, BlockPropertiesPayload, BoardFieldValue } from '../api' import { api, BinaryDownload, Block, BlockAttachment, BlockChecklistItem, BlockComment, BlockProperties, BlockPropertiesPayload, BoardAssignableUser, BoardFieldValue } from '../api'
import Autocomplete from './Autocomplete'
import ModalDialog from './ModalDialog' import ModalDialog from './ModalDialog'
function initProps(): BlockPropertiesPayload { function initProps(): BlockPropertiesPayload {
return { status: '', card_type: '', priority: '', due_date: '', assignee_id: '', sprint: '', description: '' } return { status: '', card_type: '', priority: '', due_date: '', assignee_ids: [], sprint: '', description: '' }
} }
function relativeTime(unixSec: number): string { function relativeTime(unixSec: number): string {
@@ -43,6 +64,12 @@ function initials(name: string): string {
return name.split(/\s+/).map((w) => w[0] ?? '').join('').slice(0, 2).toUpperCase() return name.split(/\s+/).map((w) => w[0] ?? '').join('').slice(0, 2).toUpperCase()
} }
function formatBytes(value: number): string {
if (value >= 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MB`
if (value >= 1024) return `${(value / 1024).toFixed(1)} KB`
return `${value} B`
}
// Build a select option list: "None/empty" always first, then defined values // Build a select option list: "None/empty" always first, then defined values
function fieldOptions(values: BoardFieldValue[], noneLabel: string): Array<{ value: string; label: string; color?: string }> { function fieldOptions(values: BoardFieldValue[], noneLabel: string): Array<{ value: string; label: string; color?: string }> {
const mapped = values.map((v) => ({ value: v.value, label: v.label, color: v.color || undefined })) const mapped = values.map((v) => ({ value: v.value, label: v.label, color: v.color || undefined }))
@@ -50,6 +77,102 @@ function fieldOptions(values: BoardFieldValue[], noneLabel: string): Array<{ val
return [{ value: '', label: noneLabel }, ...mapped] return [{ value: '', label: noneLabel }, ...mapped]
} }
type ChecklistItemRowProps = {
item: BlockChecklistItem
editing: boolean
editingTitle: string
saving: boolean
onToggle: (item: BlockChecklistItem) => void
onStartEdit: (item: BlockChecklistItem) => void
onEditingTitleChange: (value: string) => void
onSaveEdit: (item: BlockChecklistItem) => void
onCancelEdit: () => void
onDelete: (itemId: string) => void
}
function ChecklistItemRow(props: ChecklistItemRowProps) {
const { item, editing, editingTitle, saving, onToggle, onStartEdit, onEditingTitleChange, onSaveEdit, onCancelEdit, onDelete } = props
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.id })
return (
<Box
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
px: 0.5,
py: 0.25,
borderRadius: 1,
opacity: isDragging ? 0.45 : 1,
'&:hover': { bgcolor: 'action.hover' },
}}
>
<Box
{...listeners}
{...attributes}
sx={{
display: 'flex',
color: 'text.disabled',
cursor: 'grab',
touchAction: 'none',
'&:hover': { color: 'text.secondary' },
}}
>
<DragIndicatorIcon sx={{ fontSize: 16 }} />
</Box>
<Checkbox
size="small"
checked={item.done}
onChange={() => onToggle(item)}
sx={{ p: 0.5 }}
/>
{editing ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flex: 1, minWidth: 0 }}>
<TextField
value={editingTitle}
onChange={(e) => onEditingTitleChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') onSaveEdit(item)
if (e.key === 'Escape') onCancelEdit()
}}
autoFocus
fullWidth
size="small"
disabled={saving}
/>
<IconButton size="small" onClick={() => onSaveEdit(item)} disabled={saving}>
<CheckIcon fontSize="small" />
</IconButton>
<IconButton size="small" onClick={onCancelEdit} disabled={saving}>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
) : (
<Typography
variant="body2"
onClick={() => onStartEdit(item)}
sx={{
flex: 1,
minWidth: 0,
cursor: 'pointer',
textDecoration: item.done ? 'line-through' : 'none',
color: item.done ? 'text.secondary' : 'text.primary',
}}
>
{item.title}
</Typography>
)}
{editing ? null : (
<IconButton size="small" onClick={() => onDelete(item.id)}>
<DeleteIcon fontSize="small" />
</IconButton>
)}
</Box>
)
}
type CardDetailDialogProps = { type CardDetailDialogProps = {
open: boolean open: boolean
boardId: string boardId: string
@@ -73,12 +196,26 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
const [savingProps, setSavingProps] = useState(false) const [savingProps, setSavingProps] = useState(false)
const [propError, setPropError] = useState<string | null>(null) const [propError, setPropError] = useState<string | null>(null)
const [descriptionMode, setDescriptionMode] = useState<'write' | 'preview'>('write') const [descriptionMode, setDescriptionMode] = useState<'write' | 'preview'>('write')
const [assignableUsers, setAssignableUsers] = useState<BoardAssignableUser[]>([])
const [selectedAssignees, setSelectedAssignees] = useState<BoardAssignableUser[]>([])
const [loadingAssignableUsers, setLoadingAssignableUsers] = useState(false)
const [comments, setComments] = useState<BlockComment[]>([]) const [comments, setComments] = useState<BlockComment[]>([])
const [loadingComments, setLoadingComments] = useState(false) const [loadingComments, setLoadingComments] = useState(false)
const [newComment, setNewComment] = useState('') const [newComment, setNewComment] = useState('')
const [postingComment, setPostingComment] = useState(false) const [postingComment, setPostingComment] = useState(false)
const [commentError, setCommentError] = useState<string | null>(null) const [commentError, setCommentError] = useState<string | null>(null)
const [checklistItems, setChecklistItems] = useState<BlockChecklistItem[]>([])
const [loadingChecklist, setLoadingChecklist] = useState(false)
const [newChecklistTitle, setNewChecklistTitle] = useState('')
const [editingChecklistItemId, setEditingChecklistItemId] = useState<string | null>(null)
const [editingChecklistTitle, setEditingChecklistTitle] = useState('')
const [savingChecklist, setSavingChecklist] = useState(false)
const [checklistError, setChecklistError] = useState<string | null>(null)
const [attachments, setAttachments] = useState<BlockAttachment[]>([])
const [loadingAttachments, setLoadingAttachments] = useState(false)
const [uploadingAttachment, setUploadingAttachment] = useState(false)
const [attachmentError, setAttachmentError] = useState<string | null>(null)
const [confirmDelete, setConfirmDelete] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
@@ -90,7 +227,9 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
// Track last-saved values for text fields so blur/cancel can revert correctly // Track last-saved values for text fields so blur/cancel can revert correctly
const savedTitleRef = useRef('') const savedTitleRef = useRef('')
const savedDescriptionRef = useRef('') const savedDescriptionRef = useRef('')
const savedAssigneeRef = useRef('') const checklistSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
)
useEffect(() => { useEffect(() => {
if (!open || !block) return if (!open || !block) return
@@ -100,11 +239,17 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
setTitleError(null) setTitleError(null)
setPropError(null) setPropError(null)
setCommentError(null) setCommentError(null)
setChecklistError(null)
setAttachmentError(null)
setDeleteError(null) setDeleteError(null)
setConfirmDelete(false) setConfirmDelete(false)
setNewComment('') setNewComment('')
setNewChecklistTitle('')
setEditingChecklistItemId(null)
setEditingChecklistTitle('')
setCompletedAt(block.completed_at || 0) setCompletedAt(block.completed_at || 0)
setDescriptionMode('write') setDescriptionMode('write')
setSelectedAssignees([])
setLoadingProps(true) setLoadingProps(true)
api.getBlockProperties(boardId, block.id) api.getBlockProperties(boardId, block.id)
@@ -114,26 +259,44 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
card_type: p.card_type, card_type: p.card_type,
priority: p.priority, priority: p.priority,
due_date: p.due_date, due_date: p.due_date,
assignee_id: p.assignee_id, assignee_ids: p.assignee_ids || [],
sprint: p.sprint, sprint: p.sprint,
description: p.description, description: p.description,
} }
setProps(loaded) setProps(loaded)
setSelectedAssignees(p.assignees || [])
savedDescriptionRef.current = loaded.description savedDescriptionRef.current = loaded.description
savedAssigneeRef.current = loaded.assignee_id
}) })
.catch(() => { .catch(() => {
setProps(initProps()) setProps(initProps())
setSelectedAssignees([])
savedDescriptionRef.current = '' savedDescriptionRef.current = ''
savedAssigneeRef.current = ''
}) })
.finally(() => setLoadingProps(false)) .finally(() => setLoadingProps(false))
setLoadingAssignableUsers(true)
api.listBoardAssignableUsers(boardId)
.then((users: BoardAssignableUser[]) => setAssignableUsers(users || []))
.catch(() => setAssignableUsers([]))
.finally(() => setLoadingAssignableUsers(false))
setLoadingComments(true) setLoadingComments(true)
api.listBlockComments(boardId, block.id) api.listBlockComments(boardId, block.id)
.then((c) => setComments(c || [])) .then((c) => setComments(c || []))
.catch(() => setComments([])) .catch(() => setComments([]))
.finally(() => setLoadingComments(false)) .finally(() => setLoadingComments(false))
setLoadingChecklist(true)
api.listBlockChecklistItems(boardId, block.id)
.then((items: BlockChecklistItem[]) => setChecklistItems(items || []))
.catch(() => setChecklistItems([]))
.finally(() => setLoadingChecklist(false))
setLoadingAttachments(true)
api.listBlockAttachments(boardId, block.id)
.then((items: BlockAttachment[]) => setAttachments(items || []))
.catch(() => setAttachments([]))
.finally(() => setLoadingAttachments(false))
}, [open, block?.id]) }, [open, block?.id])
function saveProps(updated: BlockPropertiesPayload) { function saveProps(updated: BlockPropertiesPayload) {
@@ -200,14 +363,17 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
setProps((p) => ({ ...p, description: savedDescriptionRef.current })) setProps((p) => ({ ...p, description: savedDescriptionRef.current }))
} }
function saveAssignee() { function handleAssigneeChange(users: BoardAssignableUser[]) {
var nextProps: BlockPropertiesPayload
if (!block) return if (!block) return
const value = props_.assignee_id nextProps = { ...props_, assignee_ids: users.map((user: BoardAssignableUser) => user.id) }
setProps(nextProps)
setSelectedAssignees(users)
setSavingProps(true) setSavingProps(true)
setPropError(null) setPropError(null)
api.upsertBlockProperties(boardId, block.id, props_) api.upsertBlockProperties(boardId, block.id, nextProps)
.then(() => { .then(() => {
savedAssigneeRef.current = value
onSaved() onSaved()
}) })
.catch((err: Error) => setPropError(err.message)) .catch((err: Error) => setPropError(err.message))
@@ -231,6 +397,154 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
.catch((err: Error) => setCommentError(err.message)) .catch((err: Error) => setCommentError(err.message))
} }
function handleCreateChecklistItem() {
if (!block || !newChecklistTitle.trim()) return
setSavingChecklist(true)
setChecklistError(null)
api.createBlockChecklistItem(boardId, block.id, newChecklistTitle.trim())
.then((item: BlockChecklistItem) => {
setChecklistItems((prev: BlockChecklistItem[]) => [...prev, item])
setNewChecklistTitle('')
onSaved()
})
.catch((err: Error) => setChecklistError(err.message))
.finally(() => setSavingChecklist(false))
}
function handleToggleChecklistItem(item: BlockChecklistItem) {
if (!block) return
setChecklistError(null)
api.updateBlockChecklistItem(boardId, block.id, item.id, { title: item.title, done: !item.done })
.then((updated: BlockChecklistItem) => {
setChecklistItems((prev: BlockChecklistItem[]) => prev.map((entry: BlockChecklistItem) => entry.id === updated.id ? updated : entry))
onSaved()
})
.catch((err: Error) => setChecklistError(err.message))
}
function startEditChecklistItem(item: BlockChecklistItem) {
setEditingChecklistItemId(item.id)
setEditingChecklistTitle(item.title)
setChecklistError(null)
}
function cancelEditChecklistItem() {
setEditingChecklistItemId(null)
setEditingChecklistTitle('')
setChecklistError(null)
}
function saveChecklistItemTitle(item: BlockChecklistItem) {
const trimmed: string = editingChecklistTitle.trim()
if (!block) return
if (!trimmed) {
cancelEditChecklistItem()
return
}
if (trimmed === item.title) {
cancelEditChecklistItem()
return
}
setSavingChecklist(true)
setChecklistError(null)
api.updateBlockChecklistItem(boardId, block.id, item.id, { title: trimmed, done: item.done })
.then((updated: BlockChecklistItem) => {
setChecklistItems((prev: BlockChecklistItem[]) => prev.map((entry: BlockChecklistItem) => entry.id === updated.id ? updated : entry))
cancelEditChecklistItem()
onSaved()
})
.catch((err: Error) => setChecklistError(err.message))
.finally(() => setSavingChecklist(false))
}
function handleDeleteChecklistItem(itemId: string) {
if (!block) return
setChecklistError(null)
api.deleteBlockChecklistItem(boardId, block.id, itemId)
.then(() => {
setChecklistItems((prev: BlockChecklistItem[]) => prev.filter((item: BlockChecklistItem) => item.id !== itemId))
onSaved()
})
.catch((err: Error) => setChecklistError(err.message))
}
function handleChecklistDragEnd(event: DragEndEvent) {
const activeId: string = String(event.active.id)
const overId: string = event.over ? String(event.over.id) : ''
const oldIndex: number = checklistItems.findIndex((item: BlockChecklistItem) => item.id === activeId)
const newIndex: number = checklistItems.findIndex((item: BlockChecklistItem) => item.id === overId)
const reordered: BlockChecklistItem[] = oldIndex >= 0 && newIndex >= 0 ? arrayMove(checklistItems, oldIndex, newIndex) : checklistItems
const previous: BlockChecklistItem[] = checklistItems
const ids: string[] = reordered.map((item: BlockChecklistItem) => item.id)
if (!block || !event.over || activeId === overId || oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return
setChecklistItems(reordered)
setChecklistError(null)
api.reorderBlockChecklistItems(boardId, block.id, ids)
.then((items: BlockChecklistItem[]) => {
setChecklistItems(items || reordered)
onSaved()
})
.catch((err: Error) => {
setChecklistItems(previous)
setChecklistError(err.message)
})
}
function handleUploadAttachments(files: FileList | null) {
const selected: File[] = files ? Array.from(files) : []
let form: FormData
let index: number
if (!block || selected.length === 0) return
setUploadingAttachment(true)
setAttachmentError(null)
Promise.resolve()
.then(async () => {
for (index = 0; index < selected.length; index++) {
form = new FormData()
form.append('file', selected[index])
await api.createBlockAttachment(boardId, block.id, form)
}
})
.then(() => api.listBlockAttachments(boardId, block.id))
.then((items: BlockAttachment[]) => {
setAttachments(items || [])
onSaved()
})
.catch((err: Error) => setAttachmentError(err.message))
.finally(() => setUploadingAttachment(false))
}
function handleDownloadAttachment(item: BlockAttachment) {
if (!block) return
api.downloadBlockAttachment(boardId, block.id, item.id, item.filename)
.then((download: BinaryDownload) => {
const blob: Blob = new Blob([download.data], { type: download.contentType })
const url: string = URL.createObjectURL(blob)
const a: HTMLAnchorElement = document.createElement('a')
a.href = url
a.download = download.filename
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
})
.catch((err: Error) => setAttachmentError(err.message))
}
function handleDeleteAttachment(item: BlockAttachment) {
if (!block) return
setAttachmentError(null)
api.deleteBlockAttachment(boardId, block.id, item.id)
.then(() => {
setAttachments((prev: BlockAttachment[]) => prev.filter((entry: BlockAttachment) => entry.id !== item.id))
onSaved()
})
.catch((err: Error) => setAttachmentError(err.message))
}
function handleDelete() { function handleDelete() {
if (!block) return if (!block) return
setDeleting(true) setDeleting(true)
@@ -265,8 +579,8 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
const currentTypeColor = typeValues.find((v) => v.value === props_.card_type)?.color const currentTypeColor = typeValues.find((v) => v.value === props_.card_type)?.color
const descriptionDirty = props_.description !== savedDescriptionRef.current const descriptionDirty = props_.description !== savedDescriptionRef.current
const assigneeDirty = props_.assignee_id !== savedAssigneeRef.current
const isCompleted: boolean = completedAt > 0 const isCompleted: boolean = completedAt > 0
const checklistDoneCount: number = checklistItems.filter((item: BlockChecklistItem) => item.done).length
return ( return (
<ModalDialog open={open} onClose={onClose} maxWidth="lg" fullWidth> <ModalDialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
@@ -374,13 +688,13 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
onKeyDown={(e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) saveDescription() }} onKeyDown={(e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) saveDescription() }}
fullWidth fullWidth
multiline multiline
minRows={8} minRows={4}
disabled={savingProps} disabled={savingProps}
/> />
) : ( ) : (
<Box <Box
sx={{ sx={{
minHeight: 236, minHeight: 150,
p: 1.5, p: 1.5,
border: '1px solid', border: '1px solid',
borderColor: 'divider', borderColor: 'divider',
@@ -416,6 +730,155 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
<Divider /> <Divider />
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, mb: 0.75 }}>
<Typography variant="subtitle2" color="text.secondary">
Checklist
</Typography>
{checklistItems.length > 0 ? (
<Typography variant="caption" color="text.secondary">
{checklistDoneCount}/{checklistItems.length}
</Typography>
) : null}
</Box>
{loadingChecklist ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 1 }}>
<CircularProgress size={18} />
</Box>
) : (
<Box sx={{ display: 'grid', gap: 0.5 }}>
<DndContext sensors={checklistSensors} onDragEnd={handleChecklistDragEnd}>
<SortableContext items={checklistItems.map((item: BlockChecklistItem) => item.id)} strategy={verticalListSortingStrategy}>
{checklistItems.map((item: BlockChecklistItem) => (
<ChecklistItemRow
key={item.id}
item={item}
editing={editingChecklistItemId === item.id}
editingTitle={editingChecklistTitle}
saving={savingChecklist}
onToggle={handleToggleChecklistItem}
onStartEdit={startEditChecklistItem}
onEditingTitleChange={setEditingChecklistTitle}
onSaveEdit={saveChecklistItemTitle}
onCancelEdit={cancelEditChecklistItem}
onDelete={handleDeleteChecklistItem}
/>
))}
</SortableContext>
</DndContext>
{checklistItems.length === 0 ? (
<Typography variant="body2" color="text.disabled">
No checklist items.
</Typography>
) : null}
{checklistError ? <Alert severity="error" sx={{ mt: 1 }}>{checklistError}</Alert> : null}
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mt: 0.5 }}>
<TextField
placeholder="Add checklist item..."
value={newChecklistTitle}
onChange={(e) => setNewChecklistTitle(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleCreateChecklistItem() }}
fullWidth
size="small"
disabled={savingChecklist}
/>
<Button
size="small"
variant="outlined"
onClick={handleCreateChecklistItem}
disabled={!newChecklistTitle.trim() || savingChecklist}
>
Add
</Button>
</Box>
</Box>
)}
</Box>
<Divider />
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, mb: 0.75 }}>
<Typography variant="subtitle2" color="text.secondary">
Attachments
</Typography>
<Button component="label" size="small" startIcon={<AttachFileIcon />} disabled={uploadingAttachment}>
{uploadingAttachment ? 'Uploading...' : 'Attach'}
<input
type="file"
multiple
hidden
onChange={(event) => {
handleUploadAttachments(event.target.files)
event.target.value = ''
}}
/>
</Button>
</Box>
{loadingAttachments ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 1 }}>
<CircularProgress size={18} />
</Box>
) : (
<Box sx={{ display: 'grid', gap: 0.75 }}>
{attachments.map((item: BlockAttachment) => {
const isImage: boolean = item.content_type.startsWith('image/')
const inlineUrl: string = `/api/boards/${boardId}/blocks/${block?.id || ''}/attachments/${item.id}/download?inline=1`
return (
<Box
key={item.id}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
p: 0.75,
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
}}
>
{isImage ? (
<Box
component="img"
src={inlineUrl}
alt={item.filename}
sx={{ width: 44, height: 44, objectFit: 'cover', borderRadius: 1, border: '1px solid', borderColor: 'divider' }}
/>
) : (
<Box sx={{ width: 44, height: 44, display: 'grid', placeItems: 'center', borderRadius: 1, bgcolor: 'action.hover' }}>
<AttachFileIcon fontSize="small" />
</Box>
)}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.filename}
</Typography>
<Typography variant="caption" color="text.secondary">
{isImage ? 'Image' : (item.content_type || 'File')} · {formatBytes(item.size)}
</Typography>
</Box>
{isImage ? <ImageIcon color="primary" fontSize="small" /> : null}
<IconButton size="small" onClick={() => handleDownloadAttachment(item)}>
<DownloadIcon fontSize="small" />
</IconButton>
<IconButton size="small" onClick={() => handleDeleteAttachment(item)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
)
})}
{attachments.length === 0 ? (
<Typography variant="body2" color="text.disabled">
No attachments.
</Typography>
) : null}
{attachmentError ? <Alert severity="error" sx={{ mt: 1 }}>{attachmentError}</Alert> : null}
</Box>
)}
</Box>
<Divider />
<Typography variant="subtitle2" color="text.secondary">Activity</Typography> <Typography variant="subtitle2" color="text.secondary">Activity</Typography>
{loadingComments ? ( {loadingComments ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}> <Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
@@ -570,16 +1033,26 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
</Box> </Box>
<Box> <Box>
<Typography variant="caption" color="text.secondary">Assignee</Typography> <Typography variant="caption" color="text.secondary">Assignees</Typography>
<Autocomplete<BoardAssignableUser, true, false, false>
multiple
options={assignableUsers}
value={selectedAssignees}
loading={loadingAssignableUsers}
onChange={(_event, value: BoardAssignableUser[]) => handleAssigneeChange(value)}
getOptionLabel={(item: BoardAssignableUser) => item.display_name ? `${item.display_name} (${item.username})` : item.username}
isOptionEqualToValue={(option: BoardAssignableUser, value: BoardAssignableUser) => option.id === value.id}
disabled={savingProps || loadingProps}
renderInput={(params) => (
<TextField <TextField
fullWidth size="small" placeholder="Username" {...params}
value={props_.assignee_id} fullWidth
onChange={(e) => setProps({ ...props_, assignee_id: e.target.value })} size="small"
onKeyDown={(e) => { if (e.key === 'Enter') saveAssignee() }} placeholder="Unassigned"
helperText={assigneeDirty ? 'Press Enter to save' : undefined}
disabled={savingProps}
sx={{ mt: 0.5 }} sx={{ mt: 0.5 }}
/> />
)}
/>
</Box> </Box>
<Box> <Box>
+78 -4
View File
@@ -1,7 +1,7 @@
import PageAlert from '../components/PageAlert' import PageAlert from '../components/PageAlert'
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import { Box, Button, Paper, TextField, Typography } from '@mui/material' import { Avatar, Box, Button, Divider, Paper, TextField, Typography } from '@mui/material'
import { useEffect, useState } from 'react' import { ChangeEvent, useEffect, useRef, useState } from 'react'
import { QRCodeSVG } from 'qrcode.react' import { QRCodeSVG } from 'qrcode.react'
import { api, TOTPSetupResponse, User } from '../api' import { api, TOTPSetupResponse, User } from '../api'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
@@ -16,6 +16,8 @@ export default function AccountPage() {
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
const [avatarBusy, setAvatarBusy] = useState(false)
const avatarInputRef = useRef<HTMLInputElement>(null)
const [totpEnabled, setTOTPEnabled] = useState(false) const [totpEnabled, setTOTPEnabled] = useState(false)
const [totpSetup, setTOTPSetup] = useState<TOTPSetupResponse | null>(null) const [totpSetup, setTOTPSetup] = useState<TOTPSetupResponse | null>(null)
const [totpCode, setTOTPCode] = useState('') const [totpCode, setTOTPCode] = useState('')
@@ -55,6 +57,9 @@ export default function AccountPage() {
}, []) }, [])
const handleSave = async () => { const handleSave = async () => {
let updated: User
let message: string
setError(null) setError(null)
setSaved(false) setSaved(false)
if (password !== passwordConfirm) { if (password !== passwordConfirm) {
@@ -63,7 +68,7 @@ export default function AccountPage() {
} }
setSaving(true) setSaving(true)
try { try {
const updated = await api.updateMe({ updated = await api.updateMe({
display_name: displayName.trim(), display_name: displayName.trim(),
email: email.trim(), email: email.trim(),
password: password password: password
@@ -75,13 +80,55 @@ export default function AccountPage() {
setPasswordConfirm('') setPasswordConfirm('')
setSaved(true) setSaved(true)
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update account' message = err instanceof Error ? err.message : 'Failed to update account'
setError(message) setError(message)
} finally { } finally {
setSaving(false) setSaving(false)
} }
} }
async function handleAvatarFileChange(event: ChangeEvent<HTMLInputElement>) {
let file: File | undefined
let updated: User
let message: string
file = event.target.files?.[0]
event.target.value = ''
if (!file) return
setAvatarBusy(true)
setError(null)
setSaved(false)
try {
updated = await api.uploadMyAvatar(file)
setUser(updated)
setSaved(true)
} catch (err) {
message = err instanceof Error ? err.message : 'Failed to upload avatar'
setError(message)
} finally {
setAvatarBusy(false)
}
}
async function handleDeleteAvatar() {
let updated: User
let message: string
setAvatarBusy(true)
setError(null)
setSaved(false)
try {
updated = await api.deleteMyAvatar()
setUser(updated)
setSaved(true)
} catch (err) {
message = err instanceof Error ? err.message : 'Failed to delete avatar'
setError(message)
} finally {
setAvatarBusy(false)
}
}
const handleSetupTOTP = async () => { const handleSetupTOTP = async () => {
let setup: TOTPSetupResponse let setup: TOTPSetupResponse
let message: string let message: string
@@ -163,6 +210,30 @@ export default function AccountPage() {
<PageAlert message={error} onClose={() => setError(null)} sx={{ mb: 1 }} /> <PageAlert message={error} onClose={() => setError(null)} sx={{ mb: 1 }} />
<PageAlert severity="success" message={saved ? 'Saved.' : null} onClose={() => setSaved(false)} sx={{ mb: 1 }} /> <PageAlert severity="success" message={saved ? 'Saved.' : null} onClose={() => setSaved(false)} sx={{ mb: 1 }} />
<Box sx={{ display: 'grid', gap: 1 }}> <Box sx={{ display: 'grid', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
<Avatar
src={user?.avatar_url || undefined}
sx={{ width: 64, height: 64, fontSize: 24 }}
>
{(user?.display_name || user?.username || '?').slice(0, 2).toUpperCase()}
</Avatar>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<input
ref={avatarInputRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
hidden
onChange={handleAvatarFileChange}
/>
<Button variant="outlined" onClick={() => avatarInputRef.current?.click()} disabled={avatarBusy}>
Upload Avatar
</Button>
<Button variant="outlined" color="error" onClick={handleDeleteAvatar} disabled={avatarBusy || !user?.avatar_url}>
Remove
</Button>
</Box>
</Box>
<Divider sx={{ my: 1 }} />
<TextField label="Username" value={user?.username || ''} disabled /> <TextField label="Username" value={user?.username || ''} disabled />
<TextField <TextField
label="Display Name" label="Display Name"
@@ -174,6 +245,9 @@ export default function AccountPage() {
value={email} value={email}
onChange={(event) => setEmail(event.target.value)} onChange={(event) => setEmail(event.target.value)}
/> />
<Typography variant="subtitle2" color="text.secondary">
Password
</Typography>
<TextField <TextField
label="New Password (optional)" label="New Password (optional)"
type="password" type="password"
+6 -2
View File
@@ -19,7 +19,7 @@ import {
} from '@mui/material' } from '@mui/material'
import { MouseEvent, useEffect, useRef, useState } from 'react' import { MouseEvent, useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { api, Block, BlockProperties, BlockPropertiesPayload, Board, BoardFieldValue, GroupBy } from '../api' import { api, Block, BlockProperties, BlockPropertiesPayload, Board, BoardFieldValue, GroupBy, User } from '../api'
import BoardFormDialog from '../components/BoardFormDialog' import BoardFormDialog from '../components/BoardFormDialog'
import BoardChartView from '../components/BoardChartView' import BoardChartView from '../components/BoardChartView'
import BoardKanbanView, { ColDef } from '../components/BoardKanbanView' import BoardKanbanView, { ColDef } from '../components/BoardKanbanView'
@@ -98,6 +98,7 @@ export default function BoardPage() {
const navigate = useNavigate() const navigate = useNavigate()
const [board, setBoard] = useState<Board | null>(null) const [board, setBoard] = useState<Board | null>(null)
const [currentUser, setCurrentUser] = useState<User | null>(null)
const [blocks, setBlocks] = useState<Block[]>([]) const [blocks, setBlocks] = useState<Block[]>([])
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
@@ -155,6 +156,7 @@ export default function BoardPage() {
} }
useEffect(() => { useEffect(() => {
api.me().then(setCurrentUser).catch(() => setCurrentUser(null))
loadBoard() loadBoard()
loadBlocks() loadBlocks()
loadAllProperties() loadAllProperties()
@@ -204,7 +206,7 @@ export default function BoardPage() {
card_type: groupBy === 'type' ? colValue : '', card_type: groupBy === 'type' ? colValue : '',
priority: groupBy === 'priority' ? colValue : '', priority: groupBy === 'priority' ? colValue : '',
sprint: groupBy === 'sprint' ? colValue : '', sprint: groupBy === 'sprint' ? colValue : '',
due_date: '', assignee_id: '', description: '', due_date: '', assignee_ids: [], description: '',
} }
api.createBlock(boardId, { type: 'card', title: title.trim(), fields: JSON.stringify({ status: statusValue }) }) api.createBlock(boardId, { type: 'card', title: title.trim(), fields: JSON.stringify({ status: statusValue }) })
.then((block) => api.upsertBlockProperties(boardId, block.id, propsPayload)) .then((block) => api.upsertBlockProperties(boardId, block.id, propsPayload))
@@ -431,6 +433,7 @@ export default function BoardPage() {
properties={allProperties} properties={allProperties}
fieldValues={fieldValues} fieldValues={fieldValues}
groupBy={groupBy} groupBy={groupBy}
currentUserId={currentUser?.id}
onCardClick={setSelectedCard} onCardClick={setSelectedCard}
onAddCard={handleAddCard} onAddCard={handleAddCard}
/> />
@@ -455,6 +458,7 @@ export default function BoardPage() {
propsByBlockId={propsByBlockId} propsByBlockId={propsByBlockId}
fieldValues={fieldValues} fieldValues={fieldValues}
groupBy={groupBy} groupBy={groupBy}
currentUserId={currentUser?.id}
addingInCol={addingInCol} addingInCol={addingInCol}
newCardTitle={newCardTitle} newCardTitle={newCardTitle}
addingCard={addingCard} addingCard={addingCard}