Compare commits
3 Commits
0823947e33
...
fa44e0a762
| Author | SHA1 | Date | |
|---|---|---|---|
| fa44e0a762 | |||
| b104dcf340 | |||
| 805ac6331b |
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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: [] }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user