feat(ui,api): implement multi-select and folder management enhancements

Implements multi-select functionality in the file browser, allowing users to
perform batch actions such as deleting or moving multiple drawings at once.
Adds full CRUD support for folders, including updating folder properties
and reordering folders via a new `sort_order` column in the database.

- feat(ui): add multi-select, batch delete, and batch move in FileBrowser
- feat(api): add endpoints for updating, deleting, and reordering folders
- feat(db): add `sort_order` column and index to `workspace_folders`
- fix(editor): integrate `useHandleLibrary` for better library management
- chore(deps): update excalidraw subproject
This commit is contained in:
Tomas Dvorak
2026-05-21 13:20:44 +02:00
parent 19e7ed6ea1
commit cd22ee1ee8
7 changed files with 541 additions and 185 deletions
+113 -4
View File
@@ -61,6 +61,11 @@ type CreateFolderRequest struct {
Visibility string `json:"visibility"`
}
type UpdateFolderRequest struct {
Name *string `json:"name"`
Visibility *string `json:"visibility"`
}
type CreateProjectRequest struct {
TeamID string `json:"team_id"`
Name string `json:"name"`
@@ -922,7 +927,7 @@ func (s *Store) ListFolders(ctx context.Context, userID, teamID string) ([]Folde
return nil, ErrForbidden
}
rows, err := s.db.QueryContext(ctx, `SELECT id, team_id, project_id, parent_folder_id, name, slug, path_cache, visibility, created_by, created_at, updated_at
FROM workspace_folders WHERE team_id = ? ORDER BY path_cache ASC`, teamID)
FROM workspace_folders WHERE team_id = ? ORDER BY sort_order ASC, created_at ASC`, teamID)
if err != nil {
return nil, err
}
@@ -972,10 +977,12 @@ func (s *Store) CreateFolder(ctx context.Context, userID string, req CreateFolde
CreatedAt: now,
UpdatedAt: now,
}
var maxOrder int
s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(sort_order), -1) + 1 FROM workspace_folders WHERE team_id = ?`, teamID).Scan(&maxOrder)
_, err := s.db.ExecContext(ctx, `INSERT INTO workspace_folders
(id, team_id, project_id, parent_folder_id, name, slug, path_cache, visibility, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
folder.ID, folder.TeamID, folder.ProjectID, folder.ParentFolderID, folder.Name, folder.Slug, folder.PathCache, folder.Visibility, folder.CreatedBy, folder.CreatedAt, folder.UpdatedAt,
(id, team_id, project_id, parent_folder_id, name, slug, path_cache, visibility, created_by, created_at, updated_at, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
folder.ID, folder.TeamID, folder.ProjectID, folder.ParentFolderID, folder.Name, folder.Slug, folder.PathCache, folder.Visibility, folder.CreatedBy, folder.CreatedAt, folder.UpdatedAt, maxOrder,
)
if err != nil {
return nil, err
@@ -983,6 +990,108 @@ func (s *Store) CreateFolder(ctx context.Context, userID string, req CreateFolde
return folder, nil
}
func (s *Store) UpdateFolder(ctx context.Context, userID, folderID string, req UpdateFolderRequest) (*Folder, error) {
var teamID string
err := s.db.QueryRowContext(ctx, `SELECT team_id FROM workspace_folders WHERE id = ?`, folderID).Scan(&teamID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok {
return nil, ErrForbidden
}
var updates []string
var args []any
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
if name == "" || len(name) > 120 {
return nil, fmt.Errorf("folder name must be between 1 and 120 characters")
}
updates = append(updates, "name = ?")
args = append(args, name)
updates = append(updates, "slug = ?")
args = append(args, slugify(name))
updates = append(updates, "path_cache = ?")
args = append(args, slugify(name))
}
if req.Visibility != nil {
updates = append(updates, "visibility = ?")
args = append(args, *req.Visibility)
}
if len(updates) == 0 {
return s.GetFolder(ctx, folderID)
}
updates = append(updates, "updated_at = ?")
args = append(args, time.Now().UTC())
args = append(args, folderID)
query := "UPDATE workspace_folders SET " + strings.Join(updates, ", ") + " WHERE id = ?"
_, err = s.db.ExecContext(ctx, query, args...)
if err != nil {
return nil, err
}
return s.GetFolder(ctx, folderID)
}
func (s *Store) GetFolder(ctx context.Context, folderID string) (*Folder, error) {
var folder Folder
err := s.db.QueryRowContext(ctx, `SELECT id, team_id, project_id, parent_folder_id, name, slug, path_cache, visibility, created_by, created_at, updated_at FROM workspace_folders WHERE id = ?`, folderID).Scan(
&folder.ID, &folder.TeamID, &folder.ProjectID, &folder.ParentFolderID, &folder.Name, &folder.Slug, &folder.PathCache, &folder.Visibility, &folder.CreatedBy, &folder.CreatedAt, &folder.UpdatedAt,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &folder, nil
}
func (s *Store) DeleteFolder(ctx context.Context, userID, folderID string) error {
var teamID string
err := s.db.QueryRowContext(ctx, `SELECT team_id FROM workspace_folders WHERE id = ?`, folderID).Scan(&teamID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
return err
}
if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok {
return ErrForbidden
}
_, err = s.db.ExecContext(ctx, `DELETE FROM workspace_folders WHERE id = ?`, folderID)
return err
}
func (s *Store) ReorderFolders(ctx context.Context, userID string, folderIDs []string) error {
if len(folderIDs) == 0 {
return nil
}
var teamID string
err := s.db.QueryRowContext(ctx, `SELECT team_id FROM workspace_folders WHERE id = ?`, folderIDs[0]).Scan(&teamID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
return err
}
if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok {
return ErrForbidden
}
for i, id := range folderIDs {
_, err := s.db.ExecContext(ctx, `UPDATE workspace_folders SET sort_order = ? WHERE id = ? AND team_id = ?`, i, id, teamID)
if err != nil {
return err
}
}
return nil
}
func (s *Store) ListProjects(ctx context.Context, userID, teamID string) ([]Project, error) {
if teamID == "" {
teamID, _ = s.defaultTeamID(ctx, userID)