Compare commits

..

7 Commits

Author SHA1 Message Date
MangoPig fc5ef23af8 Refactor: improve code modularity 2026-06-29 03:06:18 +01:00
MangoPig c47eae1381 Merge branch 'Refactor/Sidebar-Usability' 2026-06-28 19:19:10 +01:00
MangoPig bcfe73411c Feat: improve sidebar tree usability 2026-06-28 19:04:48 +01:00
MangoPig 32acf6dc17 Merge branch 'Refactor/Usability' 2026-06-28 04:08:58 +01:00
MangoPig 6b87e8a1fe Refactor: improve bootstrap usability 2026-06-28 04:08:31 +01:00
MangoPig 3247f28c87 Merge branch 'Fix/Bootstrap' 2026-06-26 23:31:10 +01:00
MangoPig 891e8b83ed Fix: harden bootstrap worker flow 2026-06-26 23:30:50 +01:00
92 changed files with 8525 additions and 4614 deletions
+807
View File
@@ -50,7 +50,9 @@ var (
ErrBootstrapStructureMissing = errors.New("bootstrap structure is incomplete") ErrBootstrapStructureMissing = errors.New("bootstrap structure is incomplete")
ErrProjectNotFound = errors.New("project not found") ErrProjectNotFound = errors.New("project not found")
ErrProjectFolderNotFound = errors.New("project folder not found") ErrProjectFolderNotFound = errors.New("project folder not found")
ErrProjectItemNotFound = errors.New("project item not found")
ErrInvalidProjectFolderMove = errors.New("invalid project folder move") ErrInvalidProjectFolderMove = errors.New("invalid project folder move")
ErrInvalidProjectItemMove = errors.New("invalid project item move")
) )
type Service struct { type Service struct {
@@ -195,6 +197,15 @@ type ProjectHierarchyFolderRecord struct {
Children []ProjectHierarchyFolderRecord `json:"children"` Children []ProjectHierarchyFolderRecord `json:"children"`
} }
type ProjectTreeNodeRecord struct {
ID string `json:"id"`
Path string `json:"path"`
Label string `json:"label"`
Kind string `json:"kind"`
ItemType string `json:"itemType,omitempty"`
Children []ProjectTreeNodeRecord `json:"children,omitempty"`
}
type CreateProjectFolderInput struct { type CreateProjectFolderInput struct {
ProjectID string ProjectID string
ParentFolderPath string ParentFolderPath string
@@ -221,6 +232,27 @@ type MoveProjectFolderInput struct {
TargetIndex int TargetIndex int
} }
type CreateProjectItemInput struct {
ProjectID string
ParentFolderPath string
Name string
ItemType string
}
type DeleteProjectItemInput struct {
ProjectID string
ItemPath string
}
type MoveProjectItemInput struct {
ProjectID string
ItemPath string
ItemStableID string
ParentFolderPath string
ParentStableID string
TargetIndex int
}
type CreateProjectFolderResult struct { type CreateProjectFolderResult struct {
ProjectID string `json:"projectId"` ProjectID string `json:"projectId"`
CreatedFolder ProjectHierarchyFolderRecord `json:"createdFolder"` CreatedFolder ProjectHierarchyFolderRecord `json:"createdFolder"`
@@ -250,6 +282,27 @@ type MoveProjectFolderResult struct {
Folders []ProjectHierarchyFolderRecord `json:"folders"` Folders []ProjectHierarchyFolderRecord `json:"folders"`
} }
type CreateProjectItemResult struct {
ProjectID string `json:"projectId"`
CreatedItem ProjectTreeNodeRecord `json:"createdItem"`
Nodes []ProjectTreeNodeRecord `json:"nodes"`
}
type DeleteProjectItemResult struct {
ProjectID string `json:"projectId"`
DeletedItemStableID string `json:"deletedItemId"`
DeletedItemPath string `json:"deletedItemPath"`
Nodes []ProjectTreeNodeRecord `json:"nodes"`
}
type MoveProjectItemResult struct {
ProjectID string `json:"projectId"`
PreviousItemStableID string `json:"previousItemId"`
PreviousItemPath string `json:"previousItemPath"`
MovedItem ProjectTreeNodeRecord `json:"movedItem"`
Nodes []ProjectTreeNodeRecord `json:"nodes"`
}
type projectHierarchyFolderRow struct { type projectHierarchyFolderRow struct {
ID string ID string
Path string Path string
@@ -257,6 +310,15 @@ type projectHierarchyFolderRow struct {
Label string Label string
} }
type projectTreeNodeRow struct {
ID string
Path string
ParentPath string
Label string
Kind string
ItemType string
}
func NewService(db *database.DB, posixRoot string) *Service { func NewService(db *database.DB, posixRoot string) *Service {
return &Service{db: db, posixRoot: strings.TrimSpace(posixRoot)} return &Service{db: db, posixRoot: strings.TrimSpace(posixRoot)}
} }
@@ -815,6 +877,10 @@ func (service *Service) GetProjectTreeFolders(ctx context.Context, projectID str
return service.getProjectHierarchyFoldersByRootPath(ctx, projectID, projectTreeRootPath) return service.getProjectHierarchyFoldersByRootPath(ctx, projectID, projectTreeRootPath)
} }
func (service *Service) GetProjectTreeNodes(ctx context.Context, projectID string) ([]ProjectTreeNodeRecord, error) {
return service.getProjectTreeNodesByRootPath(ctx, projectID, projectTreeRootPath)
}
func (service *Service) getProjectHierarchyFoldersByRootPath( func (service *Service) getProjectHierarchyFoldersByRootPath(
ctx context.Context, ctx context.Context,
projectID string, projectID string,
@@ -870,6 +936,64 @@ func (service *Service) getProjectHierarchyFoldersByRootPath(
return applyProjectHierarchyFolderOrdering(folders, folderOrder), nil return applyProjectHierarchyFolderOrdering(folders, folderOrder), nil
} }
func (service *Service) getProjectTreeNodesByRootPath(
ctx context.Context,
projectID string,
rootPath func(projectSlug string) string,
) ([]ProjectTreeNodeRecord, error) {
project, err := service.loadProjectByID(ctx, projectID)
if err != nil {
return nil, err
}
rootParentPath := rootPath(project.Slug)
rows, err := service.db.Pool.Query(ctx, `
SELECT
COALESCE(node_meta.resource_id, ''),
directories.path,
COALESCE(directories.parent_path, ''),
COALESCE(node_meta.resource_name, directories.resource_name, ''),
directories.logical_type,
COALESCE(node_meta.content_json->>'type', directories.content_json->>'type', '')
FROM posix_nodes AS directories
LEFT JOIN posix_nodes AS node_meta
ON node_meta.path = directories.path || CASE
WHEN directories.logical_type = 'hierarchy_folder' THEN '/folder.json'
WHEN directories.logical_type = 'item' THEN '/item.json'
ELSE ''
END
AND node_meta.node_kind = 'file'::posix_node_kind
WHERE directories.node_kind = 'directory'::posix_node_kind
AND directories.logical_type IN ('hierarchy_folder', 'item')
AND directories.project_slug = $1
AND directories.path LIKE $2
ORDER BY directories.depth ASC, directories.path ASC;
`, project.Slug, rootParentPath+"/%")
if err != nil {
return nil, err
}
defer rows.Close()
var nodeRows []projectTreeNodeRow
for rows.Next() {
var row projectTreeNodeRow
if err := rows.Scan(&row.ID, &row.Path, &row.ParentPath, &row.Label, &row.Kind, &row.ItemType); err != nil {
return nil, err
}
nodeRows = append(nodeRows, row)
}
if err := rows.Err(); err != nil {
return nil, err
}
nodes := buildProjectTreeNodeTree(nodeRows, rootParentPath)
folderOrder := service.readProjectFolderOrder(project.Slug, rootParentPath)
return applyProjectTreeNodeOrdering(nodes, folderOrder), nil
}
func (service *Service) CreateProjectFolder(ctx context.Context, input CreateProjectFolderInput) (CreateProjectFolderResult, error) { func (service *Service) CreateProjectFolder(ctx context.Context, input CreateProjectFolderInput) (CreateProjectFolderResult, error) {
return service.createProjectHierarchyFolder(ctx, input, projectHierarchyRootPath, service.createProjectHierarchyFolderOnDisk) return service.createProjectHierarchyFolder(ctx, input, projectHierarchyRootPath, service.createProjectHierarchyFolderOnDisk)
} }
@@ -902,6 +1026,18 @@ func (service *Service) MoveProjectTreeFolder(ctx context.Context, input MovePro
return service.moveProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.moveProjectTreeFolderOnDisk) return service.moveProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.moveProjectTreeFolderOnDisk)
} }
func (service *Service) CreateProjectTreeItem(ctx context.Context, input CreateProjectItemInput) (CreateProjectItemResult, error) {
return service.createProjectTreeItem(ctx, input, projectTreeRootPath)
}
func (service *Service) DeleteProjectTreeItem(ctx context.Context, input DeleteProjectItemInput) (DeleteProjectItemResult, error) {
return service.deleteProjectTreeItem(ctx, input, projectTreeRootPath)
}
func (service *Service) MoveProjectTreeItem(ctx context.Context, input MoveProjectItemInput) (MoveProjectItemResult, error) {
return service.moveProjectTreeItem(ctx, input, projectTreeRootPath)
}
func (service *Service) createProjectHierarchyFolder( func (service *Service) createProjectHierarchyFolder(
ctx context.Context, ctx context.Context,
input CreateProjectFolderInput, input CreateProjectFolderInput,
@@ -1174,6 +1310,226 @@ func (service *Service) moveProjectHierarchyFolder(
}, nil }, nil
} }
func (service *Service) createProjectTreeItem(
ctx context.Context,
input CreateProjectItemInput,
rootPath func(projectSlug string) string,
) (CreateProjectItemResult, error) {
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil {
return CreateProjectItemResult{}, err
}
currentNodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return CreateProjectItemResult{}, err
}
parentOrderID := ""
trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath)
if trimmedParentFolderPath != "" {
parentFolder, found := findProjectTreeFolderByPath(currentNodes, trimmedParentFolderPath)
if !found {
return CreateProjectItemResult{}, ErrProjectFolderNotFound
}
parentOrderID = parentFolder.ID
}
createdPath, err := service.createProjectTreeItemOnDisk(project.Slug, trimmedParentFolderPath, input.Name, input.ItemType)
if err != nil {
return CreateProjectItemResult{}, err
}
if err := service.rebuildProjection(ctx); err != nil {
return CreateProjectItemResult{}, err
}
nodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return CreateProjectItemResult{}, err
}
createdItem, ok := findProjectTreeNodeByPath(nodes, createdPath)
if !ok || createdItem.Kind != "item" {
return CreateProjectItemResult{}, fmt.Errorf("created project item missing from projection")
}
folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
seedProjectTreeOrderParent(folderOrder, currentNodes, parentOrderID)
insertFolderOrder(folderOrder, parentOrderID, createdItem.ID, len(folderOrderChildren(folderOrder, parentOrderID)))
if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
return CreateProjectItemResult{}, err
}
nodes, err = service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return CreateProjectItemResult{}, err
}
createdItem, ok = findProjectTreeNodeByPath(nodes, createdPath)
if !ok || createdItem.Kind != "item" {
return CreateProjectItemResult{}, fmt.Errorf("created project item missing from ordered projection")
}
return CreateProjectItemResult{
ProjectID: project.ID,
CreatedItem: createdItem,
Nodes: nodes,
}, nil
}
func (service *Service) deleteProjectTreeItem(
ctx context.Context,
input DeleteProjectItemInput,
rootPath func(projectSlug string) string,
) (DeleteProjectItemResult, error) {
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil {
return DeleteProjectItemResult{}, err
}
currentNodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return DeleteProjectItemResult{}, err
}
deletedItem, found := findProjectTreeNodeByPath(currentNodes, strings.TrimSpace(input.ItemPath))
if !found || deletedItem.Kind != "item" {
return DeleteProjectItemResult{}, ErrProjectItemNotFound
}
deletedItemPath, err := service.deleteProjectTreeItemOnDisk(project.Slug, input.ItemPath)
if err != nil {
return DeleteProjectItemResult{}, err
}
if err := service.rebuildProjection(ctx); err != nil {
return DeleteProjectItemResult{}, err
}
nodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return DeleteProjectItemResult{}, err
}
if _, found := findProjectTreeNodeByPath(nodes, deletedItemPath); found {
return DeleteProjectItemResult{}, fmt.Errorf("deleted project item still present in projection")
}
folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
removeFolderOrderReference(folderOrder, deletedItem.ID)
if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
return DeleteProjectItemResult{}, err
}
nodes, err = service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return DeleteProjectItemResult{}, err
}
return DeleteProjectItemResult{
ProjectID: project.ID,
DeletedItemStableID: deletedItem.ID,
DeletedItemPath: deletedItemPath,
Nodes: nodes,
}, nil
}
func (service *Service) moveProjectTreeItem(
ctx context.Context,
input MoveProjectItemInput,
rootPath func(projectSlug string) string,
) (MoveProjectItemResult, error) {
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil {
return MoveProjectItemResult{}, err
}
currentNodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return MoveProjectItemResult{}, err
}
currentItem, found := findProjectTreeNodeByPath(currentNodes, strings.TrimSpace(input.ItemPath))
if !found || currentItem.Kind != "item" {
return MoveProjectItemResult{}, ErrProjectItemNotFound
}
movedItemStableID := currentItem.ID
providedItemStableID := strings.TrimSpace(input.ItemStableID)
if providedItemStableID != "" && providedItemStableID != movedItemStableID {
return MoveProjectItemResult{}, ErrInvalidProjectItemMove
}
parentOrderID := ""
trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath)
providedParentStableID := strings.TrimSpace(input.ParentStableID)
if trimmedParentFolderPath != "" {
parentFolder, found := findProjectTreeFolderByPath(currentNodes, trimmedParentFolderPath)
if !found {
return MoveProjectItemResult{}, ErrProjectFolderNotFound
}
parentOrderID = parentFolder.ID
if providedParentStableID != "" && providedParentStableID != parentOrderID {
return MoveProjectItemResult{}, ErrInvalidProjectItemMove
}
} else if providedParentStableID != "" {
return MoveProjectItemResult{}, ErrInvalidProjectItemMove
}
previousItemPath, movedItemPath, err := service.moveProjectTreeItemOnDisk(project.Slug, input.ItemPath, input.ParentFolderPath)
if err != nil {
return MoveProjectItemResult{}, err
}
if err := service.rebuildProjection(ctx); err != nil {
return MoveProjectItemResult{}, err
}
nodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return MoveProjectItemResult{}, err
}
movedItem, found := findProjectTreeNodeByPath(nodes, movedItemPath)
if !found || movedItem.Kind != "item" {
return MoveProjectItemResult{}, fmt.Errorf("moved project item missing from projection")
}
if previousItemPath != movedItemPath {
if _, found := findProjectTreeNodeByPath(nodes, previousItemPath); found {
return MoveProjectItemResult{}, fmt.Errorf("previous project item path still present in projection")
}
}
folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
seedProjectTreeOrderParent(folderOrder, currentNodes, parentOrderID)
removeFolderOrderReference(folderOrder, movedItemStableID)
removeFolderOrderReference(folderOrder, movedItem.ID)
insertFolderOrder(folderOrder, parentOrderID, movedItem.ID, input.TargetIndex)
if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
return MoveProjectItemResult{}, err
}
nodes, err = service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return MoveProjectItemResult{}, err
}
movedItem, found = findProjectTreeNodeByPath(nodes, movedItemPath)
if !found || movedItem.Kind != "item" {
return MoveProjectItemResult{}, fmt.Errorf("moved project item missing from ordered projection")
}
return MoveProjectItemResult{
ProjectID: project.ID,
PreviousItemStableID: movedItem.ID,
PreviousItemPath: previousItemPath,
MovedItem: movedItem,
Nodes: nodes,
}, nil
}
func (service *Service) listWorkspaces(ctx context.Context) ([]WorkspaceRecord, error) { func (service *Service) listWorkspaces(ctx context.Context) ([]WorkspaceRecord, error) {
rows, err := service.db.Pool.Query(ctx, ` rows, err := service.db.Pool.Query(ctx, `
SELECT id::text, organization_id::text, name, slug, kind::text, department_id::text, team_id::text, project_id::text SELECT id::text, organization_id::text, name, slug, kind::text, department_id::text, team_id::text, project_id::text
@@ -1796,6 +2152,218 @@ func (service *Service) moveProjectFolderOnDisk(
return folderProjectionPath, movedProjectionPath, nil return folderProjectionPath, movedProjectionPath, nil
} }
func (service *Service) createProjectTreeItemOnDisk(
projectSlug, parentFolderPath, name, itemType string,
) (string, error) {
posixRoot := strings.TrimSpace(service.posixRoot)
if posixRoot == "" {
return "", fmt.Errorf("POSIX root is not configured")
}
trimmedName := strings.TrimSpace(name)
if trimmedName == "" {
return "", fmt.Errorf("item name is required")
}
canonicalItemType := normalizeProjectTreeItemType(itemType)
containerProjectionPath := projectTreeRootPath(projectSlug)
parentDir := filepath.Join(posixRoot, filepath.FromSlash(containerProjectionPath))
if trimmedParentFolderPath := strings.TrimSpace(parentFolderPath); trimmedParentFolderPath != "" {
containerProjectionPath = trimmedParentFolderPath
parentDir = filepath.Join(posixRoot, filepath.FromSlash(containerProjectionPath))
info, err := os.Stat(parentDir)
if err != nil {
if os.IsNotExist(err) {
return "", ErrProjectFolderNotFound
}
return "", fmt.Errorf("stat parent project folder: %w", err)
}
if !info.IsDir() {
return "", ErrProjectFolderNotFound
}
}
if err := os.MkdirAll(parentDir, 0o755); err != nil {
return "", fmt.Errorf("create parent project item path: %w", err)
}
baseSlug := normalizePOSIXSlug(trimmedName)
itemDirName := slugDir("item", baseSlug)
itemDir := filepath.Join(parentDir, itemDirName)
itemSlug := baseSlug
for attempt := 2; ; attempt += 1 {
if _, err := os.Stat(itemDir); os.IsNotExist(err) {
break
} else if err != nil {
return "", fmt.Errorf("stat candidate project item: %w", err)
}
itemSlug = fmt.Sprintf("%s-%d", baseSlug, attempt)
itemDirName = slugDir("item", itemSlug)
itemDir = filepath.Join(parentDir, itemDirName)
}
if err := os.MkdirAll(itemDir, 0o755); err != nil {
return "", fmt.Errorf("create project item: %w", err)
}
itemID := uuid.NewString()
if err := writeJSONFile(filepath.Join(itemDir, "item.json"), map[string]any{
"id": itemID,
"name": trimmedName,
"slug": itemSlug,
"type": canonicalItemType,
}); err != nil {
return "", fmt.Errorf("write project item.json: %w", err)
}
if err := writeJSONFile(filepath.Join(itemDir, "schema.json"), defaultProjectTreeItemSchema(canonicalItemType)); err != nil {
return "", fmt.Errorf("write project schema.json: %w", err)
}
if err := writeJSONFile(filepath.Join(itemDir, "data.json"), defaultProjectTreeItemData(canonicalItemType, trimmedName)); err != nil {
return "", fmt.Errorf("write project data.json: %w", err)
}
return filepath.ToSlash(filepath.Join(containerProjectionPath, itemDirName)), nil
}
func (service *Service) deleteProjectTreeItemOnDisk(projectSlug, itemPath string) (string, error) {
posixRoot := strings.TrimSpace(service.posixRoot)
if posixRoot == "" {
return "", fmt.Errorf("POSIX root is not configured")
}
rootProjectionPath := projectTreeRootPath(projectSlug)
itemProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+strings.TrimSpace(itemPath))), "/")
if itemProjectionPath == "." || itemProjectionPath == rootProjectionPath || !strings.HasPrefix(itemProjectionPath, rootProjectionPath+"/") {
return "", ErrProjectItemNotFound
}
itemDir := filepath.Join(posixRoot, filepath.FromSlash(itemProjectionPath))
info, err := os.Stat(itemDir)
if err != nil {
if os.IsNotExist(err) {
return "", ErrProjectItemNotFound
}
return "", fmt.Errorf("stat project item: %w", err)
}
if !info.IsDir() {
return "", ErrProjectItemNotFound
}
if err := os.RemoveAll(itemDir); err != nil {
return "", fmt.Errorf("delete project item: %w", err)
}
return itemProjectionPath, nil
}
func (service *Service) moveProjectTreeItemOnDisk(projectSlug, itemPath, parentFolderPath string) (string, string, error) {
posixRoot := strings.TrimSpace(service.posixRoot)
if posixRoot == "" {
return "", "", fmt.Errorf("POSIX root is not configured")
}
rootProjectionPath := projectTreeRootPath(projectSlug)
itemProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+strings.TrimSpace(itemPath))), "/")
if itemProjectionPath == "." || itemProjectionPath == rootProjectionPath || !strings.HasPrefix(itemProjectionPath, rootProjectionPath+"/") {
return "", "", ErrProjectItemNotFound
}
itemDir := filepath.Join(posixRoot, filepath.FromSlash(itemProjectionPath))
info, err := os.Stat(itemDir)
if err != nil {
if os.IsNotExist(err) {
return "", "", ErrProjectItemNotFound
}
return "", "", fmt.Errorf("stat project item: %w", err)
}
if !info.IsDir() {
return "", "", ErrProjectItemNotFound
}
trimmedParentFolderPath := strings.TrimSpace(parentFolderPath)
parentProjectionPath := rootProjectionPath
parentDir := filepath.Join(posixRoot, filepath.FromSlash(parentProjectionPath))
if trimmedParentFolderPath != "" {
parentProjectionPath = strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+trimmedParentFolderPath)), "/")
if parentProjectionPath == "." || parentProjectionPath == rootProjectionPath || !strings.HasPrefix(parentProjectionPath, rootProjectionPath+"/") {
return "", "", ErrProjectFolderNotFound
}
parentDir = filepath.Join(posixRoot, filepath.FromSlash(parentProjectionPath))
}
parentInfo, err := os.Stat(parentDir)
if err != nil {
if os.IsNotExist(err) {
return "", "", ErrProjectFolderNotFound
}
return "", "", fmt.Errorf("stat project item parent: %w", err)
}
if !parentInfo.IsDir() {
return "", "", ErrProjectFolderNotFound
}
currentParentDir := filepath.Dir(itemDir)
currentBase := filepath.Base(itemDir)
itemPayload := readJSONFileMap(filepath.Join(itemDir, "item.json"))
itemID, _ := itemPayload["id"].(string)
if strings.TrimSpace(itemID) == "" {
itemID = uuid.NewString()
}
itemName, _ := itemPayload["name"].(string)
if strings.TrimSpace(itemName) == "" {
itemName = fallbackItemLabel(itemProjectionPath)
}
itemType, _ := itemPayload["type"].(string)
canonicalItemType := normalizeProjectTreeItemType(itemType)
baseSlug := strings.TrimPrefix(currentBase, "item-")
if strings.TrimSpace(baseSlug) == "" {
baseSlug = normalizePOSIXSlug(itemName)
}
itemSlug := baseSlug
itemDirName := slugDir("item", itemSlug)
destinationDir := filepath.Join(parentDir, itemDirName)
for attempt := 2; ; attempt += 1 {
if samePath(destinationDir, itemDir) {
break
}
if _, err := os.Stat(destinationDir); os.IsNotExist(err) {
break
} else if err != nil {
return "", "", fmt.Errorf("stat candidate moved project item: %w", err)
}
itemSlug = fmt.Sprintf("%s-%d", baseSlug, attempt)
itemDirName = slugDir("item", itemSlug)
destinationDir = filepath.Join(parentDir, itemDirName)
}
movedProjectionPath := filepath.ToSlash(filepath.Join(parentProjectionPath, itemDirName))
if samePath(currentParentDir, parentDir) && currentBase == itemDirName {
return itemProjectionPath, itemProjectionPath, nil
}
if err := os.Rename(itemDir, destinationDir); err != nil {
return "", "", fmt.Errorf("move project item: %w", err)
}
itemPayload["id"] = itemID
itemPayload["name"] = itemName
itemPayload["slug"] = itemSlug
itemPayload["type"] = canonicalItemType
if err := writeJSONFile(filepath.Join(destinationDir, "item.json"), itemPayload); err != nil {
return "", "", fmt.Errorf("write moved project item.json: %w", err)
}
return itemProjectionPath, movedProjectionPath, nil
}
func samePath(left, right string) bool { func samePath(left, right string) bool {
cleanLeft := filepath.Clean(left) cleanLeft := filepath.Clean(left)
cleanRight := filepath.Clean(right) cleanRight := filepath.Clean(right)
@@ -2144,6 +2712,136 @@ func buildProjectHierarchyFolderTree(rows []projectHierarchyFolderRow, rootParen
return build(rootParentPath) return build(rootParentPath)
} }
func buildProjectTreeNodeTree(rows []projectTreeNodeRow, rootParentPath string) []ProjectTreeNodeRecord {
if len(rows) == 0 {
return nil
}
nodesByPath := make(map[string]*ProjectTreeNodeRecord, len(rows))
childrenByParent := make(map[string][]string)
for _, row := range rows {
nodeKind := normalizeProjectTreeNodeKind(row.Kind)
nodeID := strings.TrimSpace(row.ID)
if nodeID == "" {
nodeID = row.Path
}
label := strings.TrimSpace(row.Label)
if label == "" {
if nodeKind == "item" {
label = fallbackItemLabel(row.Path)
} else {
label = fallbackFolderLabel(row.Path)
}
}
nodesByPath[row.Path] = &ProjectTreeNodeRecord{
ID: nodeID,
Path: row.Path,
Label: label,
Kind: nodeKind,
ItemType: normalizeProjectTreeItemType(row.ItemType),
Children: []ProjectTreeNodeRecord{},
}
childrenByParent[normalizeProjectTreeParentPath(nodeKind, row.ParentPath)] = append(childrenByParent[normalizeProjectTreeParentPath(nodeKind, row.ParentPath)], row.Path)
}
var build func(parentPath string) []ProjectTreeNodeRecord
build = func(parentPath string) []ProjectTreeNodeRecord {
childPaths := childrenByParent[parentPath]
if len(childPaths) == 0 {
return nil
}
nodes := make([]ProjectTreeNodeRecord, 0, len(childPaths))
for _, childPath := range childPaths {
node := nodesByPath[childPath]
if node == nil {
continue
}
nextNode := ProjectTreeNodeRecord{
ID: node.ID,
Path: node.Path,
Label: node.Label,
Kind: node.Kind,
ItemType: node.ItemType,
}
if node.Kind == "folder" {
nextNode.Children = build(node.Path)
}
nodes = append(nodes, nextNode)
}
return nodes
}
return build(rootParentPath)
}
func normalizeProjectTreeParentPath(kind, parentPath string) string {
if kind == "folder" && strings.HasSuffix(parentPath, "/children") {
return filepath.ToSlash(filepath.Dir(parentPath))
}
return parentPath
}
func normalizeProjectTreeNodeKind(kind string) string {
switch strings.TrimSpace(kind) {
case "item":
return "item"
case "folder", "hierarchy_folder":
return "folder"
default:
return "folder"
}
}
func applyProjectTreeNodeOrdering(nodes []ProjectTreeNodeRecord, folderOrder map[string][]string) []ProjectTreeNodeRecord {
return applyProjectTreeNodeOrderingForParent(nodes, "", folderOrder)
}
func applyProjectTreeNodeOrderingForParent(nodes []ProjectTreeNodeRecord, parentID string, folderOrder map[string][]string) []ProjectTreeNodeRecord {
if len(nodes) == 0 {
return nodes
}
nextNodes := make([]ProjectTreeNodeRecord, len(nodes))
copy(nextNodes, nodes)
for index := range nextNodes {
if nextNodes[index].Kind == "folder" {
nextNodes[index].Children = applyProjectTreeNodeOrderingForParent(nextNodes[index].Children, nextNodes[index].ID, folderOrder)
}
}
orderIDs := folderOrder[projectFolderOrderParentKey(parentID)]
if len(orderIDs) == 0 {
return nextNodes
}
rankByID := make(map[string]int, len(orderIDs))
for index, id := range orderIDs {
if _, exists := rankByID[id]; !exists {
rankByID[id] = index
}
}
sort.SliceStable(nextNodes, func(left, right int) bool {
leftRank, leftOrdered := rankByID[nextNodes[left].ID]
rightRank, rightOrdered := rankByID[nextNodes[right].ID]
if leftOrdered && rightOrdered {
return leftRank < rightRank
}
if leftOrdered != rightOrdered {
return leftOrdered
}
return false
})
return nextNodes
}
func findProjectHierarchyFolder(folders []ProjectHierarchyFolderRecord, folderID string) (ProjectHierarchyFolderRecord, bool) { func findProjectHierarchyFolder(folders []ProjectHierarchyFolderRecord, folderID string) (ProjectHierarchyFolderRecord, bool) {
for _, folder := range folders { for _, folder := range folders {
if folder.ID == folderID { if folder.ID == folderID {
@@ -2172,6 +2870,41 @@ func findProjectHierarchyFolderByPath(folders []ProjectHierarchyFolderRecord, fo
return ProjectHierarchyFolderRecord{}, false return ProjectHierarchyFolderRecord{}, false
} }
func findProjectTreeNode(nodes []ProjectTreeNodeRecord, nodeID string) (ProjectTreeNodeRecord, bool) {
for _, node := range nodes {
if node.ID == nodeID {
return node, true
}
if child, ok := findProjectTreeNode(node.Children, nodeID); ok {
return child, true
}
}
return ProjectTreeNodeRecord{}, false
}
func findProjectTreeNodeByPath(nodes []ProjectTreeNodeRecord, nodePath string) (ProjectTreeNodeRecord, bool) {
for _, node := range nodes {
if node.Path == nodePath {
return node, true
}
if child, ok := findProjectTreeNodeByPath(node.Children, nodePath); ok {
return child, true
}
}
return ProjectTreeNodeRecord{}, false
}
func findProjectTreeFolderByPath(nodes []ProjectTreeNodeRecord, folderPath string) (ProjectTreeNodeRecord, bool) {
node, ok := findProjectTreeNodeByPath(nodes, folderPath)
if !ok || node.Kind != "folder" {
return ProjectTreeNodeRecord{}, false
}
return node, true
}
func projectHierarchyRootPath(projectSlug string) string { func projectHierarchyRootPath(projectSlug string) string {
return filepath.ToSlash(filepath.Join("projects", slugDir("project", projectSlug), "children")) return filepath.ToSlash(filepath.Join("projects", slugDir("project", projectSlug), "children"))
} }
@@ -2226,6 +2959,80 @@ func fallbackFolderLabel(path string) string {
return label return label
} }
func fallbackItemLabel(path string) string {
base := filepath.Base(filepath.FromSlash(path))
trimmed := strings.TrimPrefix(base, "item-")
parts := strings.FieldsFunc(trimmed, func(r rune) bool { return r == '-' || r == '_' })
for index, part := range parts {
if part == "" {
continue
}
parts[index] = strings.ToUpper(part[:1]) + part[1:]
}
label := strings.Join(parts, " ")
if label == "" {
return base
}
return label
}
func normalizeProjectTreeItemType(itemType string) string {
switch strings.TrimSpace(strings.ToLower(itemType)) {
case "", "board", "core.board", "core.board.kanban", "kanban":
return "core.board.kanban"
case "core.doc", "doc", "document":
return "core.doc"
case "core.board.list", "list", "list-board":
return "core.board.list"
default:
return strings.TrimSpace(itemType)
}
}
func seedProjectTreeOrderParent(folderOrder map[string][]string, nodes []ProjectTreeNodeRecord, parentID string) {
children := nodes
trimmedParentID := strings.TrimSpace(parentID)
if trimmedParentID != "" {
parent, found := findProjectTreeNode(nodes, trimmedParentID)
if !found || parent.Kind != "folder" {
return
}
children = parent.Children
}
parentKey := projectFolderOrderParentKey(parentID)
if len(folderOrder[parentKey]) > 0 {
return
}
orderedIDs := make([]string, 0, len(children))
for _, child := range children {
trimmedChildID := strings.TrimSpace(child.ID)
if trimmedChildID == "" || slicesContains(orderedIDs, trimmedChildID) {
continue
}
orderedIDs = append(orderedIDs, trimmedChildID)
}
if len(orderedIDs) > 0 {
folderOrder[parentKey] = orderedIDs
}
}
func defaultProjectTreeItemSchema(itemType string) map[string]any {
return map[string]any{
"type": "object",
"itemType": normalizeProjectTreeItemType(itemType),
}
}
func defaultProjectTreeItemData(itemType, name string) map[string]any {
return map[string]any{
"title": strings.TrimSpace(name),
"itemType": normalizeProjectTreeItemType(itemType),
}
}
func slugDir(prefix, slug string) string { func slugDir(prefix, slug string) string {
trimmedSlug := strings.TrimSpace(slug) trimmedSlug := strings.TrimSpace(slug)
if trimmedSlug == "" { if trimmedSlug == "" {
@@ -36,6 +36,24 @@ type moveProjectFolderRequest struct {
TargetIndex int `json:"targetIndex"` TargetIndex int `json:"targetIndex"`
} }
type createProjectItemRequest struct {
Name string `json:"name"`
ParentFolderPath string `json:"parentFolderId"`
ItemType string `json:"itemType"`
}
type deleteProjectItemRequest struct {
ItemPath string `json:"itemId"`
}
type moveProjectItemRequest struct {
ItemPath string `json:"itemId"`
ItemStableID string `json:"itemNodeId"`
ParentFolderPath string `json:"parentFolderId"`
ParentStableID string `json:"parentNodeId"`
TargetIndex int `json:"targetIndex"`
}
func (routes apiRoutes) handleProjectFolders(w http.ResponseWriter, r *http.Request) { func (routes apiRoutes) handleProjectFolders(w http.ResponseWriter, r *http.Request) {
projectID := strings.TrimSpace(chi.URLParam(r, "projectId")) projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
if projectID == "" { if projectID == "" {
@@ -238,6 +256,30 @@ func (routes apiRoutes) handleProjectTreeFolders(w http.ResponseWriter, r *http.
}) })
} }
func (routes apiRoutes) handleProjectTree(w http.ResponseWriter, r *http.Request) {
projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
if projectID == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.")
return
}
nodes, err := routes.bootstrapService().GetProjectTreeNodes(r.Context(), projectID)
if err != nil {
routes.writeProjectTreeError(w, r, err, "load")
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"projectId": projectID,
"nodes": nodes,
},
"meta": map[string]any{
"resource": "project-tree",
},
})
}
func (routes apiRoutes) handleCreateProjectTreeFolder(w http.ResponseWriter, r *http.Request) { func (routes apiRoutes) handleCreateProjectTreeFolder(w http.ResponseWriter, r *http.Request) {
projectID := strings.TrimSpace(chi.URLParam(r, "projectId")) projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
if projectID == "" { if projectID == "" {
@@ -392,6 +434,124 @@ func (routes apiRoutes) handleMoveProjectTreeFolder(w http.ResponseWriter, r *ht
}) })
} }
func (routes apiRoutes) handleCreateProjectTreeItem(w http.ResponseWriter, r *http.Request) {
projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
if projectID == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.")
return
}
payload, ok := decodeProjectItemRequest(w, r)
if !ok {
return
}
payload.Name = strings.TrimSpace(payload.Name)
payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath)
payload.ItemType = strings.TrimSpace(payload.ItemType)
if payload.Name == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Item name is required.")
return
}
if payload.ItemType == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Item type is required.")
return
}
result, err := routes.bootstrapService().CreateProjectTreeItem(r.Context(), bootstrapservice.CreateProjectItemInput{
ProjectID: projectID,
ParentFolderPath: payload.ParentFolderPath,
Name: payload.Name,
ItemType: payload.ItemType,
})
if err != nil {
routes.writeProjectTreeError(w, r, err, "persist")
return
}
WriteJSON(w, http.StatusCreated, map[string]any{
"data": result,
"meta": map[string]any{
"resource": "project-tree-item-create",
"persisted": true,
},
})
}
func (routes apiRoutes) handleDeleteProjectTreeItem(w http.ResponseWriter, r *http.Request) {
projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
if projectID == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.")
return
}
payload := decodeDeleteProjectItemRequest(r)
if strings.TrimSpace(payload.ItemPath) == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Item ID is required.")
return
}
result, err := routes.bootstrapService().DeleteProjectTreeItem(r.Context(), bootstrapservice.DeleteProjectItemInput{
ProjectID: projectID,
ItemPath: payload.ItemPath,
})
if err != nil {
routes.writeProjectTreeError(w, r, err, "delete")
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": result,
"meta": map[string]any{
"resource": "project-tree-item-delete",
"persisted": true,
},
})
}
func (routes apiRoutes) handleMoveProjectTreeItem(w http.ResponseWriter, r *http.Request) {
projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
if projectID == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.")
return
}
payload, ok := decodeMoveProjectItemRequest(w, r)
if !ok {
return
}
payload.ItemPath = strings.TrimSpace(payload.ItemPath)
payload.ItemStableID = strings.TrimSpace(payload.ItemStableID)
payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath)
payload.ParentStableID = strings.TrimSpace(payload.ParentStableID)
if payload.ItemPath == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Item ID is required.")
return
}
result, err := routes.bootstrapService().MoveProjectTreeItem(r.Context(), bootstrapservice.MoveProjectItemInput{
ProjectID: projectID,
ItemPath: payload.ItemPath,
ItemStableID: payload.ItemStableID,
ParentFolderPath: payload.ParentFolderPath,
ParentStableID: payload.ParentStableID,
TargetIndex: payload.TargetIndex,
})
if err != nil {
routes.writeProjectTreeError(w, r, err, "move")
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": result,
"meta": map[string]any{
"resource": "project-tree-item-move",
"persisted": true,
},
})
}
func (routes apiRoutes) writeProjectFolderError(w http.ResponseWriter, r *http.Request, err error, operation string) { func (routes apiRoutes) writeProjectFolderError(w http.ResponseWriter, r *http.Request, err error, operation string) {
switch { switch {
case errors.Is(err, bootstrapservice.ErrProjectNotFound), errors.Is(err, bootstrapservice.ErrProjectFolderNotFound): case errors.Is(err, bootstrapservice.ErrProjectNotFound), errors.Is(err, bootstrapservice.ErrProjectFolderNotFound):
@@ -408,6 +568,22 @@ func (routes apiRoutes) writeProjectFolderError(w http.ResponseWriter, r *http.R
} }
} }
func (routes apiRoutes) writeProjectTreeError(w http.ResponseWriter, r *http.Request, err error, operation string) {
switch {
case errors.Is(err, bootstrapservice.ErrProjectNotFound), errors.Is(err, bootstrapservice.ErrProjectFolderNotFound), errors.Is(err, bootstrapservice.ErrProjectItemNotFound):
WriteError(w, http.StatusNotFound, RequestIDFromContext(r.Context()), "not_found", err.Error())
case errors.Is(err, bootstrapservice.ErrInvalidProjectFolderMove), errors.Is(err, bootstrapservice.ErrInvalidProjectItemMove):
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", err.Error())
default:
routes.cfg.Logger.Error(operation+" project tree", "error", err, "path", r.URL.Path)
message := "Failed to " + operation + " project tree."
if routes.cfg.Config.IsDevelopment() {
message = message + " " + err.Error()
}
WriteError(w, http.StatusInternalServerError, RequestIDFromContext(r.Context()), "project_tree_"+operation+"_failed", message)
}
}
func decodeMoveProjectFolderRequest(w http.ResponseWriter, r *http.Request) (moveProjectFolderRequest, bool) { func decodeMoveProjectFolderRequest(w http.ResponseWriter, r *http.Request) (moveProjectFolderRequest, bool) {
var payload moveProjectFolderRequest var payload moveProjectFolderRequest
@@ -438,6 +614,12 @@ func decodeDeleteProjectFolderRequest(r *http.Request) deleteProjectFolderReques
} }
} }
func decodeDeleteProjectItemRequest(r *http.Request) deleteProjectItemRequest {
return deleteProjectItemRequest{
ItemPath: strings.TrimSpace(r.URL.Query().Get("itemId")),
}
}
func decodeRenameProjectFolderRequest(w http.ResponseWriter, r *http.Request) (renameProjectFolderRequest, bool) { func decodeRenameProjectFolderRequest(w http.ResponseWriter, r *http.Request) (renameProjectFolderRequest, bool) {
var payload renameProjectFolderRequest var payload renameProjectFolderRequest
@@ -485,3 +667,51 @@ func decodeProjectFolderRequest(w http.ResponseWriter, r *http.Request) (createP
return payload, true return payload, true
} }
func decodeProjectItemRequest(w http.ResponseWriter, r *http.Request) (createProjectItemRequest, bool) {
var payload createProjectItemRequest
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&payload); err != nil {
if errors.Is(err, io.EOF) {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body is required and must be valid JSON.")
return payload, false
}
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must be valid JSON.")
return payload, false
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must contain a single JSON object.")
return payload, false
}
return payload, true
}
func decodeMoveProjectItemRequest(w http.ResponseWriter, r *http.Request) (moveProjectItemRequest, bool) {
var payload moveProjectItemRequest
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&payload); err != nil {
if errors.Is(err, io.EOF) {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body is required and must be valid JSON.")
return payload, false
}
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must be valid JSON.")
return payload, false
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must contain a single JSON object.")
return payload, false
}
return payload, true
}
+4
View File
@@ -39,11 +39,15 @@ func (routes apiRoutes) Register(router chi.Router) {
projectRouter.Patch("/folders", routes.handleRenameProjectFolder) projectRouter.Patch("/folders", routes.handleRenameProjectFolder)
projectRouter.Patch("/folders/move", routes.handleMoveProjectFolder) projectRouter.Patch("/folders/move", routes.handleMoveProjectFolder)
projectRouter.Delete("/folders", routes.handleDeleteProjectFolder) projectRouter.Delete("/folders", routes.handleDeleteProjectFolder)
projectRouter.Get("/tree", routes.handleProjectTree)
projectRouter.Get("/tree/folders", routes.handleProjectTreeFolders) projectRouter.Get("/tree/folders", routes.handleProjectTreeFolders)
projectRouter.Post("/tree/folders", routes.handleCreateProjectTreeFolder) projectRouter.Post("/tree/folders", routes.handleCreateProjectTreeFolder)
projectRouter.Patch("/tree/folders", routes.handleRenameProjectTreeFolder) projectRouter.Patch("/tree/folders", routes.handleRenameProjectTreeFolder)
projectRouter.Patch("/tree/folders/move", routes.handleMoveProjectTreeFolder) projectRouter.Patch("/tree/folders/move", routes.handleMoveProjectTreeFolder)
projectRouter.Delete("/tree/folders", routes.handleDeleteProjectTreeFolder) projectRouter.Delete("/tree/folders", routes.handleDeleteProjectTreeFolder)
projectRouter.Post("/tree/items", routes.handleCreateProjectTreeItem)
projectRouter.Patch("/tree/items/move", routes.handleMoveProjectTreeItem)
projectRouter.Delete("/tree/items", routes.handleDeleteProjectTreeItem)
}) })
if routes.cfg.Config.IsDevelopment() { if routes.cfg.Config.IsDevelopment() {
+7 -1
View File
@@ -57,7 +57,13 @@ func (runner *Runner) Run(ctx context.Context) error {
return nil return nil
} }
return err runner.logger.Error("worker claim failed", "error", err)
if err := waitForNextPoll(ctx, runner.pollInterval); err != nil {
return nil
}
continue
} }
if job == nil { if job == nil {
+48
View File
@@ -8,6 +8,7 @@ import (
"strings" "strings"
"sync" "sync"
"testing" "testing"
"time"
"moku-backend/internal/jobs" "moku-backend/internal/jobs"
) )
@@ -109,10 +110,50 @@ func TestRunnerMarksFailedWhenHandlerMissing(t *testing.T) {
} }
} }
func TestRunnerRetriesClaimErrors(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
store := &fakeJobStore{
claimErrors: []error{errors.New("relation \"background_jobs\" does not exist")},
job: &jobs.Job{
ID: "job-4",
Kind: jobs.KindBootstrapStructureMaterialize,
},
cancel: cancel,
}
runner := NewRunner(store, slog.New(slog.NewTextHandler(io.Discard, nil)), time.Millisecond)
handlerCalled := false
runner.Register(jobs.KindBootstrapStructureMaterialize, func(ctx context.Context, job jobs.Job) error {
handlerCalled = true
return nil
})
if err := runner.Run(ctx); err != nil {
t.Fatalf("runner returned error: %v", err)
}
if !handlerCalled {
t.Fatal("expected handler to be called after claim retry")
}
if store.claimAttempts < 2 {
t.Fatalf("expected at least two claim attempts, got %d", store.claimAttempts)
}
if len(store.succeeded) != 1 || store.succeeded[0] != "job-4" {
t.Fatalf("expected job to be marked succeeded once, got %#v", store.succeeded)
}
if len(store.failed) != 0 {
t.Fatalf("expected no failed jobs, got %#v", store.failed)
}
}
type fakeJobStore struct { type fakeJobStore struct {
mu sync.Mutex mu sync.Mutex
job *jobs.Job job *jobs.Job
claimed bool claimed bool
claimErrors []error
claimAttempts int
succeeded []string succeeded []string
failed []fakeFailure failed []fakeFailure
cancel context.CancelFunc cancel context.CancelFunc
@@ -126,6 +167,13 @@ type fakeFailure struct {
func (store *fakeJobStore) ClaimNext(ctx context.Context) (*jobs.Job, error) { func (store *fakeJobStore) ClaimNext(ctx context.Context) (*jobs.Job, error) {
store.mu.Lock() store.mu.Lock()
defer store.mu.Unlock() defer store.mu.Unlock()
store.claimAttempts++
if len(store.claimErrors) > 0 {
err := store.claimErrors[0]
store.claimErrors = store.claimErrors[1:]
return nil, err
}
if store.claimed || store.job == nil { if store.claimed || store.job == nil {
return nil, nil return nil, nil
+1 -1
View File
@@ -1,7 +1,7 @@
// Path: Frontend/src/app.tsx // Path: Frontend/src/app.tsx
import type { JSX } from "solid-js"; import type { JSX } from "solid-js";
import { AppShell } from "./components/shell/AppShell/AppShell"; import { AppShell } from "./components/app-shell/AppShell/AppShell";
import "./styles/main.scss"; import "./styles/main.scss";
import "./styles/user-overrides.scss"; import "./styles/user-overrides.scss";
@@ -1,17 +1,19 @@
// Path: Frontend/src/components/shell/AppShell/AppShell.tsx // Path: Frontend/src/components/app-shell/AppShell/AppShell.tsx
import { createSignal, onCleanup, onMount, Show, type JSX } from "solid-js"; import { createSignal, onCleanup, onMount, Show, type JSX } from "solid-js";
import { getDocumentTheme, setTheme, type Theme } from "../../../theme/runtime"; import { getDocumentTheme, setTheme, type Theme } from "../../../helper/themeRuntime";
import { WorkspaceHome } from "../../workspace-home/WorkspaceHome/WorkspaceHome"; import { BootstrapWizard } from "../../bootstrap/BootstrapWizard/BootstrapWizard";
import { WorkspaceHome } from "../../workspace-home/WorkspaceHome";
import { AppShellDataProvider, useAppShellData } from "../data/app-shell.context"; import { AppShellDataProvider, useAppShellData } from "../data/app-shell.context";
import { LeftRail } from "../LeftRail/LeftRail"; import { LeftRail } from "../../workspace-navigation/LeftRail/LeftRail";
import { MobileBottomNav } from "../MobileBottomNav/MobileBottomNav"; import { MobileBottomNav } from "../MobileBottomNav/MobileBottomNav";
import { MobileWorkspaceBrowser } from "../MobileWorkspaceBrowser/MobileWorkspaceBrowser"; import { MobileWorkspaceBrowser } from "../../workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser";
import { ServerDock } from "../ServerDock/ServerDock"; import { ServerDock } from "../ServerDock/ServerDock";
import { NotificationsMenu } from "../TopBar/NotificationsMenu"; import { NotificationsMenu } from "../../top-bar/TopBar/NotificationsMenu";
import { ProfileMenu } from "../TopBar/ProfileMenu"; import { ProfileMenu } from "../../top-bar/TopBar/ProfileMenu";
import { TopBar } from "../TopBar/TopBar"; import { TopBar } from "../../top-bar/TopBar/TopBar";
import { WorkspaceSidebar } from "../WorkspaceSidebar/WorkspaceSidebar"; import { WorkspaceTopBar } from "../../workspace-navigation/WorkspaceTopBar/WorkspaceTopBar";
import { WorkspaceSidebar } from "../../workspace-navigation/WorkspaceSidebar/WorkspaceSidebar";
import styles from "./AppShell.module.scss"; import styles from "./AppShell.module.scss";
type MobileWorkspaceView = "notifications" | "profile" | null; type MobileWorkspaceView = "notifications" | "profile" | null;
@@ -80,6 +82,8 @@ const AppShellContent = (): JSX.Element => {
setActiveMobileWorkspaceView(null); setActiveMobileWorkspaceView(null);
}; };
const workspaceBreadcrumb = (): string => `${appShellData.activeServer().name} / ${appShellData.activeProject().name} / Home`;
return ( return (
<div class={styles.shell} data-ui="app-shell" data-app-shell-status={appShellData.status()}> <div class={styles.shell} data-ui="app-shell" data-app-shell-status={appShellData.status()}>
<TopBar <TopBar
@@ -124,12 +128,16 @@ const AppShellContent = (): JSX.Element => {
<Show <Show
when={isMobileViewport() && activeMobileWorkspaceView() !== null} when={isMobileViewport() && activeMobileWorkspaceView() !== null}
fallback={ fallback={
<WorkspaceHome <>
<WorkspaceTopBar
sidebarCollapsed={isSidebarCollapsed()} sidebarCollapsed={isSidebarCollapsed()}
breadcrumb={workspaceBreadcrumb()}
onToggleSidebarCollapse={(): void => { onToggleSidebarCollapse={(): void => {
setIsSidebarCollapsed((collapsed) => !collapsed); setIsSidebarCollapsed((collapsed) => !collapsed);
}} }}
/> />
<WorkspaceHome />
</>
} }
> >
<div class={styles.mobileWorkspaceView} data-slot="mobile-workspace-view" data-view={activeMobileWorkspaceView() ?? undefined}> <div class={styles.mobileWorkspaceView} data-slot="mobile-workspace-view" data-view={activeMobileWorkspaceView() ?? undefined}>
@@ -162,6 +170,7 @@ const AppShellContent = (): JSX.Element => {
setIsMobileWorkspaceBrowserOpen(false); setIsMobileWorkspaceBrowserOpen(false);
}} }}
/> />
<BootstrapWizard />
</div> </div>
); );
}; };
@@ -1,4 +1,4 @@
// Path: Frontend/src/components/shell/MobileBottomNav/MobileBottomNav.tsx // Path: Frontend/src/components/app-shell/MobileBottomNav/MobileBottomNav.tsx
import { For, type JSX } from "solid-js"; import { For, type JSX } from "solid-js";
import { useAppShellData } from "../data/app-shell.context"; import { useAppShellData } from "../data/app-shell.context";
@@ -1,4 +1,4 @@
// Path: Frontend/src/components/shell/ServerDock/ServerDock.tsx // Path: Frontend/src/components/app-shell/ServerDock/ServerDock.tsx
import { For, Show, type JSX } from "solid-js"; import { For, Show, type JSX } from "solid-js";
import { useAppShellData } from "../data/app-shell.context"; import { useAppShellData } from "../data/app-shell.context";
@@ -0,0 +1,169 @@
// Path: Frontend/src/components/app-shell/data/app-shell.builders.ts
import {
activeDepartment as fallbackActiveDepartment,
activeProject as fallbackActiveProject,
activeServer as fallbackActiveServer,
activeUserProfile as fallbackActiveUserProfile,
departmentItems as fallbackDepartmentItems,
organizationAdminDockActions,
personalDockActions,
projectItems as fallbackProjectItems,
railItems as fallbackRailItems,
workspaceTree as fallbackWorkspaceTree,
type ActiveDepartment,
type ActiveProject,
type ActiveServer,
type ActiveUserProfile,
type DepartmentItem,
type ProjectItem,
type RailItem,
type WorkspaceTreeNode,
} from "./shell.data";
import type { AppShellPayload } from "./app-shell.types";
const buildAbbreviation = (name: string, fallback: string): string => {
const parts = name
.trim()
.split(/\s+/)
.filter(Boolean);
if (parts.length === 0) {
return fallback;
}
const abbreviation = parts
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("");
return abbreviation || fallback;
};
export const buildRailItems = (payload: AppShellPayload | null): readonly RailItem[] => {
if (!payload?.installation || payload.organizations.length === 0) {
return fallbackRailItems;
}
const kind = payload.installation.mode === "personal" ? "personal" : "organization";
const serverName = payload.installation.name || payload.organizations[0]?.name || payload.installation.host;
return payload.organizations.map((organization, index) => ({
id: organization.id,
label: serverName || organization.name,
abbreviation: buildAbbreviation(serverName || organization.name, kind === "personal" ? "P" : "O"),
kind,
active: index === 0,
}));
};
export const buildActiveServer = (payload: AppShellPayload | null): ActiveServer => {
const installation = payload?.installation;
const organization = payload?.organizations[0];
if (!installation || !organization) {
return fallbackActiveServer;
}
const kind = installation.mode === "personal" ? "personal" : "organization";
const serverName = installation.name || organization.name || installation.host;
return {
id: installation.id,
name: serverName || fallbackActiveServer.name,
abbreviation: buildAbbreviation(serverName, kind === "personal" ? "P" : "O"),
kind,
connectedLabel: kind === "organization" ? `${payload?.teams.length ?? 0} connected` : undefined,
subtitle: kind === "personal" ? installation.host || payload?.admin?.homeTitle || "Personal home" : undefined,
dockActions: kind === "personal" ? personalDockActions : organizationAdminDockActions,
};
};
export const buildProjectItems = (payload: AppShellPayload | null): readonly ProjectItem[] => {
if (!payload?.projects.length) {
return fallbackProjectItems;
}
return payload.projects.map((project, index) => ({
id: project.id,
name: project.name,
description: project.slug || "Persisted project workspace",
groupLabel: payload.departments.find((department) => department.id === project.departmentId)?.name || "Projects",
parentLabel:
payload.teams.find((team) => team.id === project.teamId)?.name ||
payload.departments.find((department) => department.id === project.departmentId)?.name ||
"Shared project",
meta: (() => {
const workspaceCount = payload.workspaces.filter((workspace) => workspace.projectId === project.id).length;
return workspaceCount > 0 ? `${workspaceCount} workspace${workspaceCount === 1 ? "" : "s"}` : undefined;
})(),
active: index === 0,
}));
};
export const buildActiveProject = (payload: AppShellPayload | null): ActiveProject => {
const firstProject = payload?.projects[0];
if (!firstProject) {
return fallbackActiveProject;
}
return {
id: firstProject.id,
name: firstProject.name,
};
};
export const buildDepartmentItems = (payload: AppShellPayload | null): readonly DepartmentItem[] => {
if (!payload?.departments.length) {
return fallbackDepartmentItems;
}
return payload.departments.map((department, index) => ({
id: department.id,
name: department.name,
teams: payload.teams.filter((team) => team.departmentId === department.id).map((team) => team.name),
active: index === 0,
}));
};
export const buildActiveDepartment = (payload: AppShellPayload | null): ActiveDepartment => {
const firstDepartment = payload?.departments[0];
if (!firstDepartment) {
return fallbackActiveDepartment;
}
const firstTeamName = payload?.teams.find((team) => team.departmentId === firstDepartment.id)?.name ?? "";
return {
id: firstDepartment.id,
name: firstDepartment.name,
teamName: firstTeamName,
};
};
export const buildWorkspaceTree = (payload: AppShellPayload | null): readonly WorkspaceTreeNode[] => {
if (!payload?.projects.length) {
return fallbackWorkspaceTree;
}
return [];
};
export const buildActiveUserProfile = (payload: AppShellPayload | null): ActiveUserProfile => {
if (!payload?.admin) {
return fallbackActiveUserProfile;
}
const organizationName = payload.installation?.name || payload.organizations[0]?.name || fallbackActiveServer.name;
const departmentName = payload.departments[0]?.name;
return {
name: payload.admin.displayName,
email: payload.admin.email,
roleLabel: payload.admin.isInstanceAdmin ? "Instance admin" : "Member",
contextLabel: departmentName ? `${organizationName}${departmentName}` : organizationName,
};
};
@@ -0,0 +1,97 @@
// Path: Frontend/src/components/app-shell/data/app-shell.context.tsx
import {
createContext,
createMemo,
createSignal,
onMount,
useContext,
type JSX,
} from "solid-js";
import { resolveAPIBase } from "../../../lib/api";
import {
buildActiveDepartment,
buildActiveProject,
buildActiveServer,
buildActiveUserProfile,
buildDepartmentItems,
buildProjectItems,
buildRailItems,
buildWorkspaceTree,
} from "./app-shell.builders";
import type { AppShellContextValue, AppShellPayload } from "./app-shell.types";
import { normalizeAppShellPayload } from "./app-shell.types";
const AppShellContext = createContext<AppShellContextValue>();
export const AppShellDataProvider = (props: { children: JSX.Element }): JSX.Element => {
const [status, setStatus] = createSignal<"idle" | "loading" | "success" | "error">("idle");
const [error, setError] = createSignal("");
const [payload, setPayload] = createSignal<AppShellPayload | null>(null);
const load = async (): Promise<void> => {
setStatus("loading");
setError("");
try {
const response = await fetch(`${resolveAPIBase()}/app-shell`, {
headers: {
Accept: "application/json",
},
});
const body = (await response.json()) as {
data?: AppShellPayload;
error?: { message?: string } | string;
message?: string;
};
const errorMessage =
typeof body.message === "string"
? body.message
: typeof body.error === "string"
? body.error
: body.error?.message;
if (!response.ok || !body.data) {
throw new Error(errorMessage || "Failed to load app shell state.");
}
setPayload(normalizeAppShellPayload(body.data));
setStatus("success");
} catch (loadError) {
setStatus("error");
setError(loadError instanceof Error ? loadError.message : "Failed to load app shell state.");
}
};
onMount(() => {
void load();
});
const value: AppShellContextValue = {
status,
error,
installation: createMemo(() => payload()?.installation),
railItems: createMemo(() => buildRailItems(payload())),
activeServer: createMemo(() => buildActiveServer(payload())),
activeProject: createMemo(() => buildActiveProject(payload())),
activeDepartment: createMemo(() => buildActiveDepartment(payload())),
projectItems: createMemo(() => buildProjectItems(payload())),
departmentItems: createMemo(() => buildDepartmentItems(payload())),
workspaceTree: createMemo(() => buildWorkspaceTree(payload())),
activeUserProfile: createMemo(() => buildActiveUserProfile(payload())),
reload: load,
};
return <AppShellContext.Provider value={value}>{props.children}</AppShellContext.Provider>;
};
export const useAppShellData = (): AppShellContextValue => {
const context = useContext(AppShellContext);
if (!context) {
throw new Error("useAppShellData must be used within AppShellDataProvider");
}
return context;
};
@@ -0,0 +1,130 @@
// Path: Frontend/src/components/app-shell/data/app-shell.types.ts
import type { Accessor } from "solid-js";
import type {
ActiveDepartment,
ActiveProject,
ActiveServer,
ActiveUserProfile,
DepartmentItem,
ProjectItem,
RailItem,
WorkspaceTreeNode,
} from "./shell.data";
export type AppShellInstallation = {
id: string;
name: string;
mode: "personal" | "organizational" | string;
access: string;
protocol: string;
host: string;
isBootstrapped: boolean;
materializationStatus: "not_started" | "pending" | "running" | "succeeded" | "failed" | string;
materializationError?: string;
};
export type AppShellAdmin = {
id: string;
email: string;
displayName: string;
isInstanceAdmin: boolean;
homeTitle: string;
};
export type AppShellOrganization = {
id: string;
name: string;
slug: string;
};
export type AppShellDepartment = {
id: string;
organizationId: string;
name: string;
slug: string;
};
export type AppShellTeam = {
id: string;
organizationId: string;
departmentId?: string;
name: string;
slug: string;
};
export type AppShellProject = {
id: string;
organizationId: string;
departmentId?: string;
teamId?: string;
name: string;
slug: string;
};
export type AppShellWorkspace = {
id: string;
organizationId: string;
name: string;
slug: string;
kind: "organization" | "department" | "team" | "project" | string;
departmentId?: string;
teamId?: string;
projectId?: string;
};
export type AppShellPayload = {
installation?: AppShellInstallation;
admin?: AppShellAdmin;
organizations: AppShellOrganization[];
departments: AppShellDepartment[];
teams: AppShellTeam[];
projects: AppShellProject[];
workspaces: AppShellWorkspace[];
};
export type AppShellContextValue = {
status: Accessor<"idle" | "loading" | "success" | "error">;
error: Accessor<string>;
installation: Accessor<AppShellInstallation | undefined>;
railItems: Accessor<readonly RailItem[]>;
activeServer: Accessor<ActiveServer>;
activeProject: Accessor<ActiveProject>;
activeDepartment: Accessor<ActiveDepartment>;
projectItems: Accessor<readonly ProjectItem[]>;
departmentItems: Accessor<readonly DepartmentItem[]>;
workspaceTree: Accessor<readonly WorkspaceTreeNode[]>;
activeUserProfile: Accessor<ActiveUserProfile>;
reload: () => Promise<void>;
};
export const normalizeInstallation = (
installation: AppShellInstallation | null | undefined,
): AppShellInstallation | undefined => {
if (!installation) {
return undefined;
}
const materializationStatus = installation.materializationStatus?.trim()
? installation.materializationStatus
: installation.isBootstrapped
? "succeeded"
: "not_started";
const materializationError = installation.materializationError?.trim() || undefined;
return {
...installation,
materializationStatus,
materializationError,
};
};
export const normalizeAppShellPayload = (payload: AppShellPayload | null | undefined): AppShellPayload => ({
installation: normalizeInstallation(payload?.installation),
admin: payload?.admin,
organizations: Array.isArray(payload?.organizations) ? payload.organizations : [],
departments: Array.isArray(payload?.departments) ? payload.departments : [],
teams: Array.isArray(payload?.teams) ? payload.teams : [],
projects: Array.isArray(payload?.projects) ? payload.projects : [],
workspaces: Array.isArray(payload?.workspaces) ? payload.workspaces : [],
});
@@ -0,0 +1,174 @@
// Path: Frontend/src/components/app-shell/data/shell.context-menu.ts
import { getWorkspaceCreateActions, getWorkspaceItemTypeDefinition } from "./shell.registry";
import type {
ActiveProject,
ProjectContextMenuAction,
ProjectContextMenuSection,
ProjectItem,
ProjectMenuTarget,
WorkspaceContextMenuSection,
WorkspaceContextMenuTarget,
WorkspaceStaticItem,
WorkspaceTreeNode,
} from "./shell.types";
export { getWorkspaceContextMenuEyebrow } from "./shell.registry";
export const createWorkspaceSurfaceTarget = (workspace: ActiveProject): WorkspaceContextMenuTarget => ({
id: `workspace-${workspace.id}`,
label: workspace.name,
kind: "workspace",
});
export const createWorkspaceStaticTarget = (item: WorkspaceStaticItem): WorkspaceContextMenuTarget => ({
id: item.id,
label: item.label,
kind: item.contextKind,
});
export const createWorkspaceTreeTarget = (node: WorkspaceTreeNode): WorkspaceContextMenuTarget => ({
id: node.id,
label: node.label,
...(node.kind === "folder"
? { kind: "folder" as const }
: {
kind: "item" as const,
itemType: node.itemType,
}),
});
export const getWorkspaceContextMenuSections = (
target: WorkspaceContextMenuTarget,
): readonly WorkspaceContextMenuSection[] => {
const createActions = getWorkspaceCreateActions();
const createSubmenuAction = {
id: "create",
label: "Create",
children: createActions,
} as const;
switch (target.kind) {
case "workspace":
return [
{ id: "create", label: undefined, items: [createSubmenuAction] },
{
id: "workspace",
label: undefined,
items: [
{ id: "rename-workspace", label: "Rename workspace", shortcut: { key: "enter" } },
{ id: "copy-workspace-link", label: "Copy link", shortcut: { modifiers: ["meta"], key: "c" } },
],
},
] as const;
case "home":
return [
{ id: "create", label: undefined, items: [createSubmenuAction] },
{ id: "workspace", label: undefined, items: [{ id: "open-home", label: "Open home", shortcut: { key: "enter" } }] },
] as const;
case "settings":
return [
{
id: "settings",
label: undefined,
items: [
{ id: "open-settings", label: "Open settings", shortcut: { key: "enter" } },
{ id: "copy-settings-link", label: "Copy link", shortcut: { modifiers: ["meta"], key: "c" } },
],
},
] as const;
case "folder":
return [
{ id: "open", items: [{ id: "open-folder", label: "Open folder", shortcut: { key: "enter" } }, { id: "rename-folder", label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } }] },
{ id: "create", label: undefined, items: [createSubmenuAction] },
{
id: "organize",
label: undefined,
items: [
{ id: "move-folder", label: "Move…", shortcut: { modifiers: ["meta"], key: "m" } },
{ id: "delete-folder", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
],
},
] as const;
case "item": {
const definition = getWorkspaceItemTypeDefinition(target.itemType);
const actionPrefix = definition.actionPrefix;
const nounLabel = definition.noun;
return [
{
id: `${actionPrefix}-primary`,
items: [
{ id: `open-${actionPrefix}`, label: `Open ${nounLabel}`, shortcut: { key: "enter" } },
{ id: `rename-${actionPrefix}`, label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } },
],
},
{
id: "organize",
label: undefined,
items: [
{ id: `move-${actionPrefix}`, label: "Move…", shortcut: { modifiers: ["meta"], key: "m" } },
{ id: `delete-${actionPrefix}`, label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
],
},
] as const;
}
}
};
const getProjectCreateActions = (): readonly ProjectContextMenuAction[] =>
[
{ id: "new-project", label: "New project" },
{ id: "new-folder", label: "New folder" },
] as const;
const getProjectFolderDangerActions = (): readonly ProjectContextMenuAction[] =>
[
{ id: "rename-folder", label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } },
{ id: "delete-folder", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
] as const;
export const createProjectSurfaceTarget = (label = "Projects"): ProjectMenuTarget => ({
id: "project-surface",
label,
kind: "surface",
});
export const createProjectFolderTarget = (id: string, label: string): ProjectMenuTarget => ({
id,
label,
kind: "folder",
});
export const createProjectTarget = (project: ProjectItem): ProjectMenuTarget => ({
id: project.id,
label: project.name,
kind: "project",
});
export const getProjectContextMenuEyebrow = (target: ProjectMenuTarget): string => {
switch (target.kind) {
case "surface":
return "Projects";
case "folder":
return "Folder";
case "project":
return "Project";
}
};
export const getProjectContextMenuSections = (target: ProjectMenuTarget): readonly ProjectContextMenuSection[] => {
const createActions = getProjectCreateActions();
switch (target.kind) {
case "surface":
return [{ id: "create", items: createActions }] as const;
case "folder":
return [
{ id: "create", items: createActions },
{ id: "organize", items: getProjectFolderDangerActions() },
] as const;
case "project":
return [{ id: "create", items: createActions }] as const;
}
};
@@ -0,0 +1,6 @@
// Path: Frontend/src/components/app-shell/data/shell.data.ts
export * from "./shell.types";
export * from "./shell.registry";
export * from "./shell.scaffold";
export * from "./shell.context-menu";
@@ -0,0 +1,100 @@
// Path: Frontend/src/components/app-shell/data/shell.registry.ts
import { FileText, LayoutGrid } from "../../../lib/icons";
import type {
ShellIcon,
WorkspaceContextMenuAction,
WorkspaceContextMenuTarget,
WorkspaceItemTypeDefinition,
WorkspaceItemTypeId,
WorkspaceTreeNode,
} from "./shell.types";
export const firstPartyWorkspaceItemTypes: readonly WorkspaceItemTypeDefinition[] = [
{
id: "core.doc",
label: "Doc",
shortLabel: "Doc",
icon: FileText,
noun: "doc",
actionPrefix: "doc",
defaultCreateLabel: "New doc",
includeInWorkspaceCreate: true,
description: "Rich text documents and notes.",
},
{
id: "core.board.kanban",
label: "Kanban board",
shortLabel: "Board",
icon: LayoutGrid,
noun: "board",
actionPrefix: "board",
defaultCreateLabel: "New board",
includeInWorkspaceCreate: true,
description: "Default board-style workspace item.",
},
{
id: "core.board.list",
label: "List board",
shortLabel: "Board",
icon: LayoutGrid,
noun: "board",
actionPrefix: "list-board",
defaultCreateLabel: "New list board",
description: "Alternate first-party board view prepared for the future registry.",
},
] as const;
const workspaceItemTypeMap = new Map<WorkspaceItemTypeId, WorkspaceItemTypeDefinition>(
firstPartyWorkspaceItemTypes.map((definition) => [definition.id, definition]),
);
const createUnknownWorkspaceItemTypeDefinition = (
itemType: WorkspaceItemTypeId,
): WorkspaceItemTypeDefinition => ({
id: itemType,
label: "Item",
shortLabel: "Item",
icon: FileText,
noun: "item",
actionPrefix: "item",
defaultCreateLabel: "New item",
description: "Fallback definition for unknown or future workspace item types.",
});
export const getWorkspaceItemTypeDefinition = (itemType: WorkspaceItemTypeId): WorkspaceItemTypeDefinition => {
return workspaceItemTypeMap.get(itemType) ?? createUnknownWorkspaceItemTypeDefinition(itemType);
};
export const getWorkspaceNodeIcon = (node: WorkspaceTreeNode): ShellIcon =>
node.kind === "folder" ? node.icon : getWorkspaceItemTypeDefinition(node.itemType).icon;
export const getWorkspaceCreateActions = (): readonly WorkspaceContextMenuAction[] => [
{ id: "new-folder", label: "New folder", shortcut: { modifiers: ["alt"], key: "f" } },
...firstPartyWorkspaceItemTypes
.filter((definition) => definition.includeInWorkspaceCreate)
.map((definition) => ({
id: `create-${definition.actionPrefix}`,
label: definition.defaultCreateLabel,
shortcut:
definition.id === "core.board.kanban"
? ({ modifiers: ["alt"], key: "b" } as const)
: definition.id === "core.doc"
? ({ modifiers: ["alt"], key: "d" } as const)
: undefined,
})),
];
export const getWorkspaceContextMenuEyebrow = (target: WorkspaceContextMenuTarget): string => {
switch (target.kind) {
case "workspace":
case "home":
return "Workspace";
case "settings":
return "Configuration";
case "folder":
return "Folder";
case "item":
return getWorkspaceItemTypeDefinition(target.itemType).shortLabel;
}
};
@@ -0,0 +1,222 @@
// Path: Frontend/src/components/app-shell/data/shell.scaffold.ts
import {
Bell,
CircleHelp,
FileText,
Folder,
Home,
Keyboard,
LayoutGrid,
ListCollapse,
LogOut,
Repeat,
Search,
Settings,
Shield,
User,
} from "../../../lib/icons";
import type {
ActiveDepartment,
ActiveProject,
ActiveServer,
ActiveUserProfile,
DepartmentItem,
MobileBottomNavItem,
NotificationItem,
ProfileMenuSection,
ProjectItem,
RailItem,
ServerDockAction,
SidebarHeaderAction,
TopBarAction,
WorkspaceStaticItem,
WorkspaceTreeNode,
} from "./shell.types";
export const personalDockActions: readonly ServerDockAction[] = [
{ id: "account", label: "Account", icon: User },
{ id: "settings", label: "Settings", icon: Settings },
] as const;
export const organizationAdminDockActions: readonly ServerDockAction[] = [
{ id: "members", label: "Members", icon: User },
{ id: "server", label: "Server", icon: Settings },
] as const;
export const railItems: readonly RailItem[] = [
{ id: "personal-server", label: "Personal Server Name", abbreviation: "P", kind: "personal" },
{ id: "organization-server", label: "Organization Name", abbreviation: "O", kind: "organization", active: true },
{ id: "design-review", label: "Design Review", abbreviation: "D", kind: "organization" },
] as const;
export const activeServer: ActiveServer = {
id: "organization-server",
name: "Organization Name",
abbreviation: "O",
kind: "organization",
connectedLabel: "12 connected",
dockActions: organizationAdminDockActions,
};
export const activeProject: ActiveProject = {
id: "general",
name: "General",
};
export const activeDepartment: ActiveDepartment = {
id: "product",
name: "Product",
teamName: "Design Systems",
};
export const projectItems: readonly ProjectItem[] = [
{
id: "general",
name: "General",
description: "Default shared project",
groupLabel: "Shared space",
parentLabel: "Workspace home",
meta: "1 workspace",
active: true,
},
{
id: "operations",
name: "Operations",
description: "Cross-team planning and delivery",
groupLabel: "Team folders",
parentLabel: "Shared Services",
meta: "2 workspaces",
},
{
id: "hiring",
name: "Hiring",
description: "Candidate pipeline and interview loops",
groupLabel: "Team folders",
parentLabel: "People Ops",
meta: "1 workspace",
},
] as const;
export const departmentItems: readonly DepartmentItem[] = [
{ id: "product", name: "Product", teams: ["Design Systems", "Research Ops"], active: true },
{ id: "engineering", name: "Engineering", teams: ["Platform", "Realtime Collaboration"] },
{ id: "operations", name: "Operations", teams: ["Shared Services", "People Ops"] },
] as const;
export const workspaceStaticItems: readonly WorkspaceStaticItem[] = [
{ id: "home", label: "Home", icon: Home, active: true, contextKind: "home" },
{ id: "workspace-settings", label: "Settings", icon: Settings, contextKind: "settings" },
] as const;
export const workspaceTree: readonly WorkspaceTreeNode[] = [
{
id: "product-workspace",
label: "Product",
kind: "folder",
icon: Folder,
children: [
{ id: "roadmap-board", label: "Roadmap", kind: "item", itemType: "core.board.kanban", active: true },
{ id: "launch-brief", label: "Launch Brief", kind: "item", itemType: "core.doc" },
{
id: "research-folder",
label: "Research",
kind: "folder",
icon: Folder,
children: [
{ id: "interviews-doc", label: "Interviews", kind: "item", itemType: "core.doc" },
{ id: "signals-board", label: "Signals", kind: "item", itemType: "core.board.kanban", meta: "2" },
],
},
],
},
{
id: "design-folder",
label: "Design",
kind: "folder",
icon: Folder,
children: [
{ id: "system-doc", label: "Design System", kind: "item", itemType: "core.doc" },
{ id: "review-board", label: "Review Queue", kind: "item", itemType: "core.board.kanban" },
],
},
{ id: "general-notes", label: "General Notes", kind: "item", itemType: "core.doc" },
] as const;
export const workspaceSidebarHeaderActions: readonly SidebarHeaderAction[] = [
{ id: "search-workspace", label: "Search workspace", icon: Search },
{ id: "toggle-workspace-folders", label: "Collapse all folders", icon: ListCollapse },
] as const;
export const mobileBottomNavItems: readonly MobileBottomNavItem[] = [
{ id: "home", label: "Home", icon: Home, active: true },
{ id: "search", label: "Search", icon: Search },
{ id: "browse", label: "Browse", icon: Folder },
] as const;
export const topBarActions: readonly TopBarAction[] = [{ id: "search", label: "Search", icon: Search }] as const;
export const notificationItems: readonly NotificationItem[] = [
{
id: "comment-design-systems",
title: "New comment on Design Systems",
contextLabel: "Product • Review thread updated",
timeLabel: "2m ago",
unread: true,
},
{
id: "sprint-platform",
title: "Sprint updated in Platform",
contextLabel: "Engineering • Scope changed",
timeLabel: "15m ago",
unread: true,
},
{
id: "member-joined",
title: "New member joined Operations",
contextLabel: "Organization Name • Access granted",
timeLabel: "1h ago",
},
{
id: "daily-summary",
title: "Daily summary is ready",
contextLabel: "General • 8 updates across boards",
timeLabel: "Today, 8:00 AM",
},
] as const;
export const unreadNotificationCount = notificationItems.filter((item) => item.unread).length;
export const activeUserProfile: ActiveUserProfile = {
name: "Demo Account",
email: "demo@moku.work",
roleLabel: "Founder · Product",
contextLabel: "Organization Name • Design Systems",
};
export const profileMenuSections: readonly ProfileMenuSection[] = [
{
id: "account",
items: [
{ id: "profile", label: "Profile", icon: User },
{ id: "account-settings", label: "Account Settings", icon: Settings },
{ id: "notifications", label: "Notifications", icon: Bell },
{ id: "security", label: "Security", icon: Shield },
],
},
{
id: "preferences",
items: [
{ id: "keyboard-shortcuts", label: "Keyboard Shortcuts", icon: Keyboard },
{ id: "theme-preferences", label: "Theme Preferences", icon: Settings },
{ id: "help-support", label: "Help & Support", icon: CircleHelp },
],
},
{
id: "session",
items: [
{ id: "switch-account", label: "Switch Account", icon: Repeat },
{ id: "sign-out", label: "Sign Out", icon: LogOut, tone: "danger" },
],
},
] as const;
@@ -0,0 +1,233 @@
// Path: Frontend/src/components/app-shell/data/shell.types.ts
import type { Component } from "solid-js";
export type ShellIconProps = {
class?: string;
size?: number;
strokeWidth?: number;
};
export type ShellIcon = Component<ShellIconProps>;
export type RailItem = {
id: string;
label: string;
abbreviation: string;
kind: "personal" | "organization";
active?: boolean;
};
export type ServerDockAction = {
id: string;
label: string;
icon: ShellIcon;
};
export type ActiveServer = {
id: string;
name: string;
abbreviation: string;
kind: "personal" | "organization";
connectedLabel?: string;
subtitle?: string;
dockActions: readonly ServerDockAction[];
};
export type ActiveProject = {
id: string;
name: string;
};
export type ActiveDepartment = {
id: string;
name: string;
teamName: string;
};
export type DepartmentItem = {
id: string;
name: string;
teams: readonly string[];
active?: boolean;
};
export type ProjectItem = {
id: string;
name: string;
description: string;
groupLabel?: string;
parentLabel?: string;
meta?: string;
active?: boolean;
};
export type ProjectMenuTarget =
| {
id: string;
label: string;
kind: "surface";
}
| {
id: string;
label: string;
kind: "folder";
}
| {
id: string;
label: string;
kind: "project";
};
export type ProjectContextMenuAction = {
id: string;
label: string;
tone?: "default" | "danger";
shortcut?: WorkspaceContextMenuShortcut;
children?: readonly ProjectContextMenuAction[];
};
export type ProjectContextMenuSection = {
id: string;
label?: string;
items: readonly ProjectContextMenuAction[];
};
export type SidebarItem = {
id: string;
label: string;
icon: ShellIcon;
active?: boolean;
meta?: string;
};
export type WorkspaceStaticKind = "workspace" | "home" | "settings";
export type WorkspaceItemTypeId = string;
export type WorkspaceStaticItem = SidebarItem & {
contextKind: WorkspaceStaticKind;
};
export type WorkspaceFolderNode = {
id: string;
path?: string;
label: string;
kind: "folder";
icon: ShellIcon;
active?: boolean;
meta?: string;
children?: readonly WorkspaceTreeNode[];
};
export type WorkspaceItemNode = {
id: string;
path?: string;
label: string;
kind: "item";
itemType: WorkspaceItemTypeId;
active?: boolean;
meta?: string;
children?: undefined;
};
export type WorkspaceTreeNode = WorkspaceFolderNode | WorkspaceItemNode;
export type WorkspaceItemTypeDefinition = {
id: WorkspaceItemTypeId;
label: string;
shortLabel: string;
icon: ShellIcon;
noun: string;
actionPrefix: string;
defaultCreateLabel: string;
includeInWorkspaceCreate?: boolean;
description?: string;
};
export type SidebarHeaderAction = {
id: string;
label: string;
icon: ShellIcon;
};
export type TopBarAction = {
id: string;
label: string;
icon: ShellIcon;
};
export type MobileBottomNavItem = {
id: string;
label: string;
icon: ShellIcon;
active?: boolean;
};
export type WorkspaceContextMenuTarget =
| {
id: string;
label: string;
kind: WorkspaceStaticKind;
}
| {
id: string;
label: string;
kind: "folder";
}
| {
id: string;
label: string;
kind: "item";
itemType: WorkspaceItemTypeId;
};
export type WorkspaceContextMenuAction = {
id: string;
label: string;
tone?: "default" | "danger";
shortcut?: WorkspaceContextMenuShortcut;
children?: readonly WorkspaceContextMenuAction[];
};
export type WorkspaceContextMenuShortcutModifier = "meta" | "alt" | "shift";
export type WorkspaceContextMenuShortcutKey = "b" | "c" | "d" | "delete" | "enter" | "f" | "m" | "r";
export type WorkspaceContextMenuShortcut = {
modifiers?: readonly WorkspaceContextMenuShortcutModifier[];
key: WorkspaceContextMenuShortcutKey;
};
export type WorkspaceContextMenuSection = {
id: string;
label?: string;
items: readonly WorkspaceContextMenuAction[];
};
export type NotificationItem = {
id: string;
title: string;
contextLabel: string;
timeLabel: string;
unread?: boolean;
};
export type ProfileMenuAction = {
id: string;
label: string;
icon: ShellIcon;
tone?: "default" | "danger";
};
export type ProfileMenuSection = {
id: string;
items: readonly ProfileMenuAction[];
};
export type ActiveUserProfile = {
name: string;
email: string;
roleLabel: string;
contextLabel: string;
};
@@ -0,0 +1,173 @@
// Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.data.ts
export type BootstrapStepKey = "persona" | "instance" | "mode" | "admin" | "structure";
export type BootstrapStepDefinition = {
id: BootstrapStepKey;
title: string;
buttonLabel: string;
};
export type InstanceForm = {
protocol: "http" | "https";
access: "local" | "remote";
host: string;
};
export type ModeForm = {
mode: "personal" | "organizational";
name: string;
};
export type AdminForm = {
displayName: string;
email: string;
password: string;
};
export type StructureForm = {
departmentName: string;
teamName: string;
projectName: string;
};
export type BootstrapPersona = "personal" | "enthusiast" | "team" | "organization";
export type BootstrapPersonaDefinition = {
id: BootstrapPersona;
title: string;
isAvailable: boolean;
bestFor: string;
bullets: readonly string[];
defaults: {
protocol: InstanceForm["protocol"];
access: InstanceForm["access"];
host: string;
mode: ModeForm["mode"];
namePlaceholder: string;
departmentName: string;
teamName: string;
projectName: string;
};
};
export const bootstrapStepDefinitions: readonly BootstrapStepDefinition[] = [
{ id: "persona", title: "What are you setting up your server for?", buttonLabel: "Continue" },
{ id: "instance", title: "Connection details", buttonLabel: "Save and continue" },
{ id: "mode", title: "Server identity", buttonLabel: "Save and continue" },
{ id: "admin", title: "Admin account", buttonLabel: "Save and continue" },
{ id: "structure", title: "Initial structure", buttonLabel: "Submit" },
];
export const defaultInstanceForm: InstanceForm = {
protocol: "http",
access: "local",
host: "localhost",
};
export const defaultModeForm: ModeForm = {
mode: "personal",
name: "",
};
export const defaultAdminForm: AdminForm = {
displayName: "Admin",
email: "admin@example.com",
password: "",
};
export const personalStructureDefaults = {
departmentName: "Default",
teamName: "Personal",
};
export const organizationalStructureDefaults = {
departmentName: "Department",
teamName: "Team",
};
export const defaultStructureForm: StructureForm = {
...personalStructureDefaults,
projectName: "Project",
};
export const bootstrapPersonaDefinitions: readonly BootstrapPersonaDefinition[] = [
{
id: "personal",
title: "Personal",
isAvailable: true,
bestFor: "Best for low maintenance, personal use",
bullets: ["Preconfigured for personal use", "Low setup time", "Easy to manage"],
defaults: {
protocol: "http",
access: "local",
host: "localhost",
mode: "personal",
namePlaceholder: "Personal Server",
departmentName: "Default",
teamName: "Personal",
projectName: "Project",
},
},
{
id: "enthusiast",
title: "Self Hosted Enthusiast",
isAvailable: true,
bestFor: "Best for people who want to customize their server",
bullets: ["Networking knowledge", "Comfortable with tinkering", "Willing to troubleshoot issues"],
defaults: {
protocol: "https",
access: "remote",
host: "moku.local",
mode: "personal",
namePlaceholder: "Personal Server",
departmentName: "Default",
teamName: "Personal",
projectName: "Project",
},
},
{
id: "team",
title: "Team",
isAvailable: true,
bestFor: "Best for low maintenance but for small team",
bullets: ["Built-in collaboration with low setup time", "Keeps the shared structure simple", "Good for a small product, design, or delivery team"],
defaults: {
protocol: "http",
access: "local",
host: "localhost",
mode: "organizational",
namePlaceholder: "Team Server",
departmentName: "Default",
teamName: "Core Team",
projectName: "Project",
},
},
{
id: "organization",
title: "Organization",
isAvailable: true,
bestFor: "Best for multiple teams and shared ownership",
bullets: ["SME to Organization", "Fine grained access control", "Better fit for teams with multiple departments"],
defaults: {
protocol: "https",
access: "remote",
host: "workspace.example.com",
mode: "organizational",
namePlaceholder: "Organization server name",
departmentName: "Operations",
teamName: "Platform Team",
projectName: "Moku",
},
},
];
export const workspaceHomeFieldTooltips = {
protocol: "Usually people use http for a local-only setup and https when the server will be reached over a domain or reverse proxy.",
access: "Usually people use local when Moku is only reached on the same machine or LAN, and remote when they plan to reach it from another network or public domain.",
host: "Examples people usually set here are localhost, moku.local, or a real domain like workspace.example.com depending on how they plan to reach the server.",
serverName: "This is the friendly name people usually give the server itself, for example Personal Server, Ronald's Server, Homelab, Studio, or Workspace.",
department: "Departments are the highest-level grouping for work. People usually use names like Default, Operations, Product, Design, or Engineering.",
team: "Teams sit inside a department. Common examples are Personal, Platform Team, Core Team, Delivery, or Design Systems.",
project: "Projects are the workspace or initiative people work inside. Common examples are Project, Shared Workspace, Moku, Client Portal, or Website Redesign.",
} as const;
@@ -0,0 +1,469 @@
// Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.hook.ts
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js";
import { createStore } from "solid-js/store";
import {
bootstrapPersonaDefinitions,
bootstrapStepDefinitions,
defaultAdminForm,
defaultInstanceForm,
defaultModeForm,
defaultStructureForm,
organizationalStructureDefaults,
personalStructureDefaults,
type AdminForm,
type BootstrapPersona,
type BootstrapPersonaDefinition,
type BootstrapStepDefinition,
type BootstrapStepKey,
type InstanceForm,
type ModeForm,
type StructureForm,
} from "./BootstrapWizard.data";
import { submitBootstrapStepRequest } from "./bootstrapWizard.api";
type AppShellBootstrapAdapter = {
installation: () => { isBootstrapped?: boolean; materializationStatus?: string; materializationError?: string } | undefined;
status: () => string;
reload: () => Promise<void>;
};
export type BootstrapSubmissionState = {
status: "idle" | "submitting" | "success" | "error";
error: string;
};
export type MaterializationState = "not_started" | "pending" | "running" | "succeeded" | "failed";
export type FieldTooltipState = {
text: string;
left: number;
top: number;
placement: "top" | "bottom";
};
const initialSubmissionState = (): BootstrapSubmissionState => ({
status: "idle",
error: "",
});
const materializationPollIntervalMs = 2000;
export const useBootstrapWizard = (appShellData: AppShellBootstrapAdapter) => {
const [instanceForm, setInstanceForm] = createStore<InstanceForm>({ ...defaultInstanceForm });
const [modeForm, setModeForm] = createStore<ModeForm>({ ...defaultModeForm });
const [adminForm, setAdminForm] = createStore<AdminForm>({ ...defaultAdminForm });
const [structureForm, setStructureForm] = createStore<StructureForm>({ ...defaultStructureForm });
const [selectedPersona, setSelectedPersona] = createSignal<BootstrapPersona>("enthusiast");
const [hasChosenPersona, setHasChosenPersona] = createSignal(false);
const [stepState, setStepState] = createStore<Record<BootstrapStepKey, BootstrapSubmissionState>>({
persona: initialSubmissionState(),
instance: initialSubmissionState(),
mode: initialSubmissionState(),
admin: initialSubmissionState(),
structure: initialSubmissionState(),
});
const [isBootstrapStateResolved, setIsBootstrapStateResolved] = createSignal(false);
const [isBootstrapComplete, setIsBootstrapComplete] = createSignal(false);
const [isWizardOpen, setIsWizardOpen] = createSignal(false);
const [isFinishingBootstrapFlow, setIsFinishingBootstrapFlow] = createSignal(false);
const [currentStepIndex, setCurrentStepIndex] = createSignal(0);
const [fieldTooltip, setFieldTooltip] = createSignal<FieldTooltipState | null>(null);
const installation = createMemo(() => appShellData.installation());
const materializationState = createMemo<MaterializationState>(() => {
const status = installation()?.materializationStatus;
switch (status) {
case "pending":
case "running":
case "failed":
case "succeeded":
case "not_started":
return status;
default:
return installation()?.isBootstrapped ? "succeeded" : "not_started";
}
});
const isBootstrapPersisted = createMemo(() => installation()?.isBootstrapped ?? false);
const isMaterializationInFlight = createMemo(() => materializationState() === "pending" || materializationState() === "running");
const hasMaterializationFailed = createMemo(() => materializationState() === "failed");
const showBootstrapFinishingState = createMemo(() => isFinishingBootstrapFlow() && (isMaterializationInFlight() || hasMaterializationFailed()));
const materializationStatusLabel = createMemo(() => {
switch (materializationState()) {
case "pending":
return "Materialization queued";
case "running":
return "Materialization running";
case "failed":
return "Materialization failed";
case "succeeded":
return "Ready";
default:
return "Not started";
}
});
const materializationMessage = createMemo(() => {
if (isMaterializationInFlight()) {
return "Your bootstrap is saved. The worker is still creating the POSIX skeleton and rebuilding the app shell index.";
}
if (hasMaterializationFailed()) {
return installation()?.materializationError || "Bootstrap saved, but background materialization did not finish cleanly.";
}
return "";
});
const personaDefinition = createMemo<BootstrapPersonaDefinition>(() => bootstrapPersonaDefinitions.find((persona) => persona.id === selectedPersona()) ?? bootstrapPersonaDefinitions[0]!);
const selectedPersonaIsAvailable = createMemo(() => personaDefinition().isAvailable);
const usesCondensedBootstrapFlow = createMemo(() => selectedPersona() === "personal" || selectedPersona() === "team");
const activeBootstrapSteps = createMemo<readonly BootstrapStepDefinition[]>(() => {
if (usesCondensedBootstrapFlow()) {
return [bootstrapStepDefinitions[0]!, bootstrapStepDefinitions[2]!, bootstrapStepDefinitions[3]!];
}
return bootstrapStepDefinitions;
});
const activeWizardSteps = createMemo(() => activeBootstrapSteps().filter((step) => step.id !== "persona"));
createEffect(() => {
const defaults = personaDefinition().defaults;
setInstanceForm({
protocol: defaults.protocol,
access: defaults.access,
host: defaults.host,
});
setModeForm("mode", defaults.mode);
setStructureForm({
departmentName: defaults.departmentName,
teamName: defaults.teamName,
projectName: defaults.projectName,
});
});
createEffect(() => {
if (modeForm.mode === "personal") {
setStructureForm("departmentName", personalStructureDefaults.departmentName);
setStructureForm("teamName", personalStructureDefaults.teamName);
return;
}
if (structureForm.departmentName === personalStructureDefaults.departmentName) {
setStructureForm("departmentName", organizationalStructureDefaults.departmentName);
}
if (structureForm.teamName === personalStructureDefaults.teamName) {
setStructureForm("teamName", organizationalStructureDefaults.teamName);
}
});
const resetWizardState = (): void => {
setSelectedPersona("enthusiast");
setHasChosenPersona(false);
setInstanceForm({ ...defaultInstanceForm });
setModeForm({ ...defaultModeForm });
setAdminForm({ ...defaultAdminForm });
setStructureForm({ ...defaultStructureForm });
setStepState({
persona: initialSubmissionState(),
instance: initialSubmissionState(),
mode: initialSubmissionState(),
admin: initialSubmissionState(),
structure: initialSubmissionState(),
});
setCurrentStepIndex(0);
setIsFinishingBootstrapFlow(false);
};
createEffect(() => {
const shellStatus = appShellData.status();
if (shellStatus === "idle" || shellStatus === "loading") {
return;
}
if (shellStatus !== "success") {
return;
}
if (!isBootstrapPersisted()) {
setIsFinishingBootstrapFlow(false);
resetWizardState();
}
setIsBootstrapComplete(isBootstrapPersisted() && !isMaterializationInFlight());
setIsWizardOpen(!isBootstrapPersisted() || showBootstrapFinishingState());
setIsBootstrapStateResolved(true);
});
createEffect(() => {
if (!isFinishingBootstrapFlow()) {
return;
}
if (isMaterializationInFlight() || hasMaterializationFailed()) {
return;
}
setIsFinishingBootstrapFlow(false);
setIsWizardOpen(false);
});
createEffect(() => {
if (!isBootstrapPersisted() || !isMaterializationInFlight()) {
return;
}
let cancelled = false;
let timeoutId: number | undefined;
const scheduleReload = (): void => {
timeoutId = window.setTimeout(async () => {
if (cancelled) {
return;
}
await appShellData.reload();
if (!cancelled && isBootstrapPersisted() && isMaterializationInFlight()) {
scheduleReload();
}
}, materializationPollIntervalMs);
};
scheduleReload();
onCleanup(() => {
cancelled = true;
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
});
});
const bootstrapNamePlaceholder = (): string => personaDefinition().defaults.namePlaceholder;
const bootstrapStepCount = createMemo(() => activeWizardSteps().length);
const currentStep = createMemo<BootstrapStepDefinition>(() => activeBootstrapSteps()[currentStepIndex()] ?? activeBootstrapSteps()[0] ?? bootstrapStepDefinitions[0]!);
const currentWizardStepIndex = createMemo(() => {
const visibleIndex = activeWizardSteps().findIndex((step) => step.id === currentStep().id);
return visibleIndex >= 0 ? visibleIndex : 0;
});
const wizardProgressPercent = createMemo(() => {
const totalSteps = bootstrapStepCount();
const activeIndex = Math.max(currentWizardStepIndex(), 0);
if (totalSteps <= 1) {
return 100;
}
return (activeIndex / (totalSteps - 1)) * 100;
});
const wizardProgressFillWidth = createMemo(() => {
if (currentStepIndex() <= 0) {
return `${wizardProgressPercent()}%`;
}
return `calc(${wizardProgressPercent()}% + ((var(--control-size-md) - var(--space-2)) / 2))`;
});
const currentStepState = createMemo<BootstrapSubmissionState>(() => stepState[currentStep().id]);
const isFirstStep = (): boolean => currentStepIndex() === 0;
const isLastStep = (): boolean => currentStepIndex() === activeBootstrapSteps().length - 1;
const canDismissWizard = (): boolean => isBootstrapPersisted() && !isMaterializationInFlight();
createEffect(() => {
setCurrentStepIndex((index) => Math.min(index, activeBootstrapSteps().length - 1));
});
const submitStep = async (step: BootstrapStepKey, payload: unknown): Promise<boolean> => {
setStepState(step, { status: "submitting", error: "" });
try {
await submitBootstrapStepRequest(step, payload);
setStepState(step, {
status: "success",
error: "",
});
return true;
} catch (error) {
setStepState(step, {
status: "error",
error: error instanceof Error ? error.message : `Bootstrap ${step} request failed.`,
});
return false;
}
};
const payloadForStep = (step: BootstrapStepKey): unknown => {
switch (step) {
case "instance":
return instanceForm;
case "mode":
return modeForm;
case "admin":
return adminForm;
case "structure":
return structureForm;
}
};
const applyPersonaSelection = (persona: BootstrapPersona): void => {
const definition = bootstrapPersonaDefinitions.find((candidate) => candidate.id === persona);
if (!definition?.isAvailable) {
return;
}
setSelectedPersona(persona);
setHasChosenPersona(true);
setStepState("persona", {
status: "success",
error: "",
});
setCurrentStepIndex((index) => Math.min(index + 1, activeBootstrapSteps().length - 1));
};
const statusLabel = (state: BootstrapSubmissionState): string => {
switch (state.status) {
case "submitting":
return "Sending";
case "error":
return "Request failed";
default:
return "";
}
};
const submitCurrentStep = async (): Promise<void> => {
const step = currentStep().id;
if (step === "persona") {
applyPersonaSelection(selectedPersona());
return;
}
if (step === "mode" && usesCondensedBootstrapFlow() && stepState.instance.status !== "success") {
const didPersistInstanceDefaults = await submitStep("instance", instanceForm);
if (!didPersistInstanceDefaults) {
return;
}
}
const didSucceed = await submitStep(step, payloadForStep(step));
if (!didSucceed) {
return;
}
if (step === "admin" && usesCondensedBootstrapFlow()) {
const didPersistStructureDefaults = await submitStep("structure", structureForm);
if (!didPersistStructureDefaults) {
return;
}
}
if (isLastStep()) {
await appShellData.reload();
const shouldShowFinishingState = isBootstrapPersisted() && (isMaterializationInFlight() || hasMaterializationFailed());
setIsFinishingBootstrapFlow(shouldShowFinishingState);
setIsBootstrapComplete(isBootstrapPersisted() && !isMaterializationInFlight());
setIsWizardOpen(!isBootstrapPersisted() || shouldShowFinishingState);
setIsBootstrapStateResolved(true);
return;
}
setCurrentStepIndex((index) => Math.min(index + 1, activeBootstrapSteps().length - 1));
};
const showFieldTooltip = (target: HTMLElement, text: string): void => {
const rect = target.getBoundingClientRect();
const placement = rect.top > 96 ? "top" : "bottom";
const viewportPadding = 20;
const left = Math.min(Math.max(rect.left + rect.width / 2, viewportPadding), window.innerWidth - viewportPadding);
const top = placement === "top" ? rect.top - 10 : rect.bottom + 10;
setFieldTooltip({ text, left, top, placement });
};
const hideFieldTooltip = (): void => {
setFieldTooltip(null);
};
const stepStatusLabel = (step: BootstrapStepDefinition): string => {
const state = stepState[step.id];
if (state.status === "success") {
return "Done";
}
if (state.status === "error") {
return "Needs retry";
}
return "";
};
return {
instanceForm,
setInstanceForm,
modeForm,
setModeForm,
adminForm,
setAdminForm,
structureForm,
setStructureForm,
selectedPersona,
setSelectedPersona,
hasChosenPersona,
stepState,
isBootstrapStateResolved,
isBootstrapComplete,
isWizardOpen,
setIsWizardOpen,
isFinishingBootstrapFlow,
setIsFinishingBootstrapFlow,
fieldTooltip,
materializationState,
isMaterializationInFlight,
hasMaterializationFailed,
showBootstrapFinishingState,
materializationStatusLabel,
materializationMessage,
personaDefinition,
selectedPersonaIsAvailable,
usesCondensedBootstrapFlow,
activeWizardSteps,
bootstrapNamePlaceholder,
bootstrapStepCount,
currentStep,
currentWizardStepIndex,
wizardProgressFillWidth,
currentStepState,
isFirstStep,
canDismissWizard,
resetWizardState,
handleCurrentStepSubmit: (event: SubmitEvent & { currentTarget: HTMLFormElement; target: Element }): void => {
event.preventDefault();
void submitCurrentStep();
},
applyPersonaSelection,
statusLabel,
showFieldTooltip,
hideFieldTooltip,
stepStatusLabel,
navigateBack: (): void => {
setCurrentStepIndex((index) => Math.max(index - 1, 0));
},
navigateToVisibleStep: (index: number): void => {
setCurrentStepIndex(index + 1);
},
};
};
@@ -1,3 +1,5 @@
/* Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.module.scss */
.viewport, .viewport,
.wizardLayer { .wizardLayer {
--workspace-content-max-width: var(--content-width-wide); --workspace-content-max-width: var(--content-width-wide);
@@ -268,11 +270,249 @@
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.fieldLabelRow {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.fieldInfoButton {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
background: transparent;
color: var(--color-text-muted);
cursor: help;
outline: none;
}
.fieldInfoButton:hover,
.fieldInfoButton:focus-visible {
color: var(--color-text);
}
.fieldTooltip {
position: fixed;
z-index: calc(var(--z-modal, 1000) + 4);
pointer-events: none;
transform: translateX(-50%);
max-width: min(18rem, calc(100vw - 2rem));
}
.fieldTooltip[data-placement="top"] {
transform: translate(-50%, -100%);
}
.fieldTooltip[data-placement="bottom"] {
transform: translate(-50%, 0);
}
.fieldTooltipBubble {
position: relative;
padding: 0.45rem 0.6rem;
border: 1px solid color-mix(in srgb, var(--color-border-strong) 48%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-surface-elevated, var(--color-surface)) 96%, black 4%);
box-shadow: var(--shadow-soft);
color: var(--color-text);
white-space: normal;
text-align: left;
line-height: 1.35;
}
.fieldTooltipBubble::after {
content: "";
position: absolute;
left: 50%;
width: 0.55rem;
height: 0.55rem;
background: color-mix(in srgb, var(--color-surface-elevated, var(--color-surface)) 96%, black 4%);
transform: translateX(-50%) rotate(45deg);
}
.fieldTooltip[data-placement="top"] .fieldTooltipBubble::after {
top: calc(100% - 0.3rem);
border-right: 1px solid color-mix(in srgb, var(--color-border-strong) 48%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--color-border-strong) 48%, transparent);
}
.fieldTooltip[data-placement="bottom"] .fieldTooltipBubble::after {
bottom: calc(100% - 0.3rem);
border-top: 1px solid color-mix(in srgb, var(--color-border-strong) 48%, transparent);
border-left: 1px solid color-mix(in srgb, var(--color-border-strong) 48%, transparent);
}
.fieldHelp { .fieldHelp {
@include text-caption; @include text-caption;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.personaIntro {
display: grid;
gap: var(--space-2);
}
.personaGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.personaCard {
appearance: none;
position: relative;
display: grid;
padding: var(--space-3);
border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent);
border-radius: var(--radius-xl);
background: color-mix(in srgb, var(--color-surface-secondary) 84%, transparent);
text-align: left;
overflow: hidden;
transition:
transform 180ms var(--easing-standard),
border-color 160ms var(--easing-standard),
background 160ms var(--easing-standard),
box-shadow 160ms var(--easing-standard);
}
.personaCard:hover,
.personaCard:focus-visible,
.personaCard[data-selected="true"] {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--bootstrap-accent) 32%, var(--color-border));
background: color-mix(in srgb, var(--bootstrap-accent) 7%, var(--color-surface));
box-shadow: var(--shadow-soft);
}
.personaCard:focus-visible {
outline: none;
box-shadow:
var(--shadow-soft),
0 0 0 3px color-mix(in srgb, var(--bootstrap-accent) 16%, transparent);
}
.personaCard[data-available="false"] {
opacity: 0.9;
}
.personaCard[data-available="false"]:hover,
.personaCard[data-available="false"]:focus-visible,
.personaCard[data-available="false"][data-selected="true"] {
transform: none;
border-color: color-mix(in srgb, var(--color-border) 88%, transparent);
background: color-mix(in srgb, var(--color-surface-secondary) 84%, transparent);
box-shadow: none;
}
.personaCardMedia {
position: relative;
display: flex;
align-items: center;
justify-content: center;
justify-self: center;
align-self: center;
width: min(100%, 16rem);
aspect-ratio: 1 / 1;
border: 1px dashed color-mix(in srgb, var(--color-border-strong) 40%, transparent);
border-radius: calc(var(--radius-xl) - var(--space-1));
background: linear-gradient(
135deg,
color-mix(in srgb, var(--color-surface-elevated) 92%, transparent),
color-mix(in srgb, var(--color-surface-secondary) 88%, transparent)
);
transition:
filter 180ms var(--easing-standard),
opacity 180ms var(--easing-standard),
transform 180ms var(--easing-standard);
}
.personaCardBody {
position: absolute;
inset: 0;
display: grid;
align-content: space-between;
gap: var(--space-3);
padding: var(--space-3);
pointer-events: none;
z-index: 1;
}
.personaCardTitle {
@include text-title;
margin: 0;
max-width: min(100%, 14rem);
padding: 0.35rem 0.65rem;
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-surface) 84%, transparent);
backdrop-filter: blur(10px);
color: var(--color-text);
}
.personaCardDetails {
display: grid;
gap: var(--space-2);
max-height: 0;
opacity: 0;
overflow: hidden;
align-self: end;
padding: var(--space-3);
border-radius: var(--radius-lg);
background: linear-gradient(
180deg,
color-mix(in srgb, var(--color-surface) 18%, transparent),
color-mix(in srgb, var(--color-surface) 92%, transparent)
);
backdrop-filter: blur(12px);
transition:
max-height 180ms var(--easing-standard),
opacity 160ms var(--easing-standard);
}
.personaCard:hover .personaCardMedia,
.personaCard:focus-visible .personaCardMedia,
.personaCard[data-selected="true"] .personaCardMedia {
filter: brightness(0.72);
opacity: 0.92;
transform: scale(0.985);
}
.personaCard:hover .personaCardDetails,
.personaCard:focus-visible .personaCardDetails,
.personaCard[data-selected="true"] .personaCardDetails {
max-height: 12rem;
opacity: 1;
}
.personaCard[data-available="false"]:hover .personaCardMedia,
.personaCard[data-available="false"]:focus-visible .personaCardMedia,
.personaCard[data-available="false"][data-selected="true"] .personaCardMedia {
filter: brightness(0.82);
opacity: 0.96;
transform: none;
}
.personaBestFor,
.personaBulletList {
margin: 0;
color: var(--color-text);
}
.personaBulletList {
padding-left: 1rem;
display: grid;
gap: 0.25rem;
}
.personaAvailability {
@include text-caption;
margin: 0;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.field input, .field input,
.field select { .field select {
min-height: var(--control-size-md); min-height: var(--control-size-md);
@@ -370,7 +610,7 @@
.primaryButton:hover, .primaryButton:hover,
.secondaryButton:hover, .secondaryButton:hover,
.wizardCloseButton:hover, .wizardCloseButton:hover,
.wizardStepButton:hover { .wizardProgressStep:hover {
transform: translateY(-1px); transform: translateY(-1px);
} }
@@ -445,72 +685,93 @@
.wizardBody { .wizardBody {
display: grid; display: grid;
grid-template-columns: minmax(17rem, 20rem) minmax(0, 1fr);
gap: var(--space-4); gap: var(--space-4);
min-height: 0; min-height: 0;
} }
.wizardSidebar { .wizardProgress {
position: relative;
display: grid; display: grid;
gap: var(--space-4);
align-content: start;
}
.wizardSidebarSection {
gap: var(--space-3); gap: var(--space-3);
} }
.wizardSteps { .wizardProgressTrack {
display: grid; position: absolute;
gap: var(--space-2); left: calc((var(--control-size-md) - var(--space-2)) / 2);
right: calc((var(--control-size-md) - var(--space-2)) / 2);
top: calc((var(--control-size-md) - var(--space-2)) / 2);
height: 2px;
background: color-mix(in srgb, var(--color-border) 72%, transparent);
transform: translateY(-50%);
pointer-events: none;
} }
.wizardStepButton { .wizardProgressFill {
width: 100%; height: 100%;
display: grid; border-radius: 999px;
grid-template-columns: auto minmax(0, 1fr); background: color-mix(in srgb, var(--bootstrap-accent) 72%, white 8%);
transition: width 220ms var(--easing-standard);
}
.wizardProgressSteps {
display: flex;
align-items: center; align-items: center;
text-align: left; justify-content: space-between;
padding: var(--space-2) var(--space-3); gap: 0;
border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent);
background: color-mix(in srgb, var(--color-surface-secondary) 84%, transparent);
} }
.wizardStepButton[data-active="true"] { .wizardProgressStep {
border-color: color-mix(in srgb, var(--bootstrap-accent) 42%, transparent); position: relative;
background: color-mix(in srgb, var(--bootstrap-accent) 10%, var(--color-surface)); z-index: 1;
flex: 0 0 auto;
display: inline-flex;
justify-content: center;
justify-items: center;
text-align: center;
padding: 0;
border: 0;
background: transparent;
} }
.wizardStepButton:disabled { .wizardProgressStep:disabled {
opacity: 0.56;
cursor: not-allowed; cursor: not-allowed;
transform: none; transform: none;
} }
.wizardStepIndex { .wizardProgressIndex {
width: calc(var(--control-size-md) - var(--space-2)); width: calc(var(--control-size-md) - var(--space-2));
height: calc(var(--control-size-md) - var(--space-2)); height: calc(var(--control-size-md) - var(--space-2));
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: var(--radius-pill); border-radius: 999px;
background: color-mix(in srgb, var(--color-surface) 80%, transparent); border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent);
background: color-mix(in srgb, var(--color-surface) 92%, transparent);
color: var(--color-text-muted);
transition:
border-color 160ms var(--easing-standard),
background 160ms var(--easing-standard),
color 160ms var(--easing-standard),
box-shadow 160ms var(--easing-standard),
transform 180ms var(--easing-standard);
}
.wizardProgressStep[data-active="true"] .wizardProgressIndex,
.wizardProgressStep[data-complete="true"] .wizardProgressIndex {
border-color: color-mix(in srgb, var(--bootstrap-accent) 42%, transparent);
background: color-mix(in srgb, var(--bootstrap-accent) 12%, var(--color-surface));
color: var(--color-text); color: var(--color-text);
} }
.wizardStepCopy { .wizardProgressStep[data-active="true"] .wizardProgressIndex {
min-width: 0; box-shadow: 0 0 0 3px color-mix(in srgb, var(--bootstrap-accent) 14%, transparent);
display: grid;
gap: 0.125rem;
} }
.wizardStepCopy strong { .wizardProgressStep:not(:disabled):hover .wizardProgressIndex,
@include text-label; .wizardProgressStep:not(:disabled):focus-visible .wizardProgressIndex {
} transform: translateY(-1px);
border-color: color-mix(in srgb, var(--bootstrap-accent) 36%, var(--color-border));
.wizardStepCopy small { color: var(--color-text);
@include text-caption;
color: var(--color-text-muted);
} }
.wizardStepPanel { .wizardStepPanel {
@@ -518,7 +779,116 @@
gap: var(--space-3); gap: var(--space-3);
} }
.wizardFinishPanel {
display: grid;
gap: var(--space-4);
justify-items: stretch;
padding: var(--space-2) 0 0;
min-height: min(14rem, 32dvh);
align-content: center;
}
.wizardFinishShell {
display: grid;
gap: var(--space-4);
width: min(100%, 40rem);
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.wizardFinishStatusRow {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: var(--space-3);
align-items: start;
}
.wizardFinishIndicator {
width: 2.5rem;
height: 2.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--bootstrap-accent) 18%, transparent);
background: color-mix(in srgb, var(--bootstrap-accent) 8%, transparent);
}
.wizardFinishIndicator[data-status="failed"] {
border-color: color-mix(in srgb, var(--color-danger-border, var(--color-border)) 44%, transparent);
background: color-mix(in srgb, var(--color-danger-surface, var(--color-surface-secondary)) 36%, transparent);
}
.wizardFinishSpinner {
width: 1.25rem;
height: 1.25rem;
border-radius: 999px;
border: 2px solid color-mix(in srgb, var(--bootstrap-accent) 18%, transparent);
border-top-color: var(--bootstrap-accent);
animation: wizardFinishSpin 900ms linear infinite;
}
.wizardFinishIndicator[data-status="failed"] .wizardFinishSpinner {
border: 2px solid color-mix(in srgb, var(--color-danger-border, var(--color-border)) 22%, transparent);
border-top-color: var(--color-danger-text, var(--color-text));
animation: none;
transform: rotate(45deg);
border-radius: var(--radius-sm);
width: 1rem;
height: 1rem;
}
.wizardFinishCopy {
display: grid;
gap: var(--space-1);
min-width: 0;
}
.wizardFinishTitle {
@include text-title;
margin: 0;
}
.wizardFinishDescription,
.wizardFinishMessage,
.wizardFinishHint {
margin: 0;
color: var(--color-text-muted);
}
.wizardFinishMessage[data-status="failed"] {
color: var(--color-danger-text, var(--color-text));
}
.wizardFinishHint {
@include text-caption;
}
.wizardFinishActions {
display: flex;
gap: var(--space-3);
flex-wrap: wrap;
padding-top: var(--space-1);
}
@keyframes wizardFinishSpin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@include respond-down(tablet) { @include respond-down(tablet) {
.personaGrid {
grid-template-columns: 1fr;
}
.summaryGrid, .summaryGrid,
.wizardBody { .wizardBody {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -564,21 +934,20 @@
} }
.wizardHeader, .wizardHeader,
.wizardBody, .wizardBody {
.wizardSidebar {
gap: var(--space-3); gap: var(--space-3);
} }
.wizardSteps { .wizardProgressSteps {
grid-auto-flow: column; justify-content: flex-start;
grid-auto-columns: minmax(10rem, 1fr); gap: var(--space-8);
overflow-x: auto; overflow-x: auto;
padding-bottom: var(--space-1); padding-bottom: var(--space-1);
scrollbar-width: thin; scrollbar-width: thin;
} }
.wizardStepButton { .wizardProgressStep {
min-width: 10rem; min-width: calc(var(--control-size-md) - var(--space-2));
} }
.wizardFormActions { .wizardFormActions {
@@ -0,0 +1,287 @@
// Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.parts.tsx
import { For, Show, type JSX } from "solid-js";
import { CircleHelp } from "../../../lib/icons";
import {
organizationalStructureDefaults,
workspaceHomeFieldTooltips,
type AdminForm,
type BootstrapPersona,
type BootstrapPersonaDefinition,
type BootstrapStepDefinition,
type BootstrapStepKey,
type InstanceForm,
type ModeForm,
type StructureForm,
} from "./BootstrapWizard.data";
import styles from "./BootstrapWizard.module.scss";
type BootstrapSubmissionState = {
status: "idle" | "submitting" | "success" | "error";
error: string;
};
type MaterializationState = "not_started" | "pending" | "running" | "succeeded" | "failed";
type TooltipHandlers = {
onShowTooltip: (target: HTMLElement, text: string) => void;
onHideTooltip: () => void;
};
type FieldLabelWithTooltipProps = TooltipHandlers & {
label: string;
tooltip?: string;
};
export const FieldLabelWithTooltip = (props: FieldLabelWithTooltipProps): JSX.Element => (
<span class={styles.fieldLabelRow}>
<span class={styles.fieldLabel}>{props.label}</span>
<Show when={props.tooltip}>
<button
type="button"
class={styles.fieldInfoButton}
aria-label={`${props.label} help: ${props.tooltip}`}
onMouseEnter={(event): void => props.onShowTooltip(event.currentTarget, props.tooltip!)}
onMouseLeave={props.onHideTooltip}
onFocus={(event): void => props.onShowTooltip(event.currentTarget, props.tooltip!)}
onBlur={props.onHideTooltip}
>
<CircleHelp size={14} strokeWidth={2} />
</button>
</Show>
</span>
);
type BootstrapFinishingStateProps = {
materializationState: MaterializationState;
statusLabel: string;
message: string;
isInFlight: boolean;
hasFailed: boolean;
onClose: () => void;
};
export const BootstrapFinishingState = (props: BootstrapFinishingStateProps): JSX.Element => (
<div class={styles.wizardFinishPanel} data-slot="bootstrap-wizard-finishing-state">
<div class={styles.wizardFinishShell}>
<div class={styles.wizardFinishStatusRow}>
<div class={styles.wizardFinishIndicator} data-status={props.materializationState} aria-hidden="true">
<div class={styles.wizardFinishSpinner} />
</div>
<div class={styles.wizardFinishCopy}>
<span class={styles.wizardStepEyebrow}>Bootstrap status</span>
<h3 class={styles.wizardFinishTitle}>Finishing setup</h3>
<p class={styles.wizardFinishDescription}>
We saved your initial bootstrap. The server is finishing the last background setup steps now.
</p>
</div>
</div>
<div class={styles.statusBadge} data-status={props.materializationState}>{props.statusLabel}</div>
<Show when={props.message}>
<p class={styles.wizardFinishMessage} data-status={props.materializationState}>{props.message}</p>
</Show>
<Show when={props.isInFlight}>
<p class={styles.wizardFinishHint}>This window will close automatically when setup is complete.</p>
</Show>
</div>
<Show when={props.hasFailed}>
<div class={styles.wizardFinishActions}>
<button type="button" class={styles.secondaryButton} onClick={props.onClose}>Close</button>
</div>
</Show>
</div>
);
type BootstrapWizardProgressProps = {
steps: readonly BootstrapStepDefinition[];
currentStepId: BootstrapStepKey;
currentWizardStepIndex: number;
stepState: Record<BootstrapStepKey, BootstrapSubmissionState>;
bootstrapStepCount: number;
wizardProgressFillWidth: string;
stepStatusLabel: (step: BootstrapStepDefinition) => string;
onSelectStep: (index: number) => void;
};
export const BootstrapWizardProgress = (props: BootstrapWizardProgressProps): JSX.Element => (
<div class={styles.wizardProgress} data-slot="bootstrap-wizard-progress">
<div class={styles.wizardProgressTrack} aria-hidden="true">
<div class={styles.wizardProgressFill} style={{ width: props.wizardProgressFillWidth }} />
</div>
<nav class={styles.wizardProgressSteps} aria-label="Bootstrap steps" style={{ "--wizard-progress-step-count": props.bootstrapStepCount }}>
<For each={props.steps}>
{(step, index): JSX.Element => (
<button
type="button"
class={styles.wizardProgressStep}
data-active={step.id === props.currentStepId ? "true" : "false"}
data-complete={props.stepState[step.id].status === "success" ? "true" : "false"}
disabled={index() > props.currentWizardStepIndex}
onClick={(): void => {
if (index() <= props.currentWizardStepIndex) {
props.onSelectStep(index());
}
}}
aria-label={`Step ${index() + 1}${props.stepStatusLabel(step) ? `, ${props.stepStatusLabel(step)}` : ""}`}
>
<span class={styles.wizardProgressIndex}>{index() + 1}</span>
</button>
)}
</For>
</nav>
</div>
);
type BootstrapPersonaStepProps = {
personas: readonly BootstrapPersonaDefinition[];
hasChosenPersona: boolean;
selectedPersona: BootstrapPersona;
selectedPersonaIsAvailable: boolean;
onSelectPersona: (persona: BootstrapPersona) => void;
};
export const BootstrapPersonaStep = (props: BootstrapPersonaStepProps): JSX.Element => (
<>
<div class={styles.personaGrid}>
<For each={props.personas}>
{(persona): JSX.Element => (
<button
type="button"
class={styles.personaCard}
data-selected={props.hasChosenPersona && persona.id === props.selectedPersona ? "true" : "false"}
data-available={persona.isAvailable ? "true" : "false"}
aria-pressed={props.hasChosenPersona && persona.id === props.selectedPersona}
onClick={(): void => props.onSelectPersona(persona.id)}
>
<div class={styles.personaCardMedia} aria-hidden="true" />
<div class={styles.personaCardBody}>
<h4 class={styles.personaCardTitle}>{persona.title}</h4>
<div class={styles.personaCardDetails}>
<p class={styles.personaBestFor}>{persona.bestFor}</p>
<ul class={styles.personaBulletList}>
<For each={persona.bullets}>{(bullet): JSX.Element => <li>{bullet}</li>}</For>
</ul>
<Show when={!persona.isAvailable}><p class={styles.personaAvailability}>Coming later</p></Show>
</div>
</div>
</button>
)}
</For>
</div>
<Show when={!props.selectedPersonaIsAvailable}>
<p class={styles.fieldHelp}>Only <strong>Self Hosted Enthusiast</strong> is wired up right now. The other setup paths will come next.</p>
</Show>
</>
);
type BootstrapInstanceStepProps = TooltipHandlers & {
instanceForm: InstanceForm;
onProtocolChange: (value: InstanceForm["protocol"]) => void;
onAccessChange: (value: InstanceForm["access"]) => void;
onHostChange: (value: string) => void;
};
export const BootstrapInstanceStep = (props: BootstrapInstanceStepProps): JSX.Element => (
<>
<label class={styles.field}>
<FieldLabelWithTooltip label="Protocol" tooltip={workspaceHomeFieldTooltips.protocol} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<select value={props.instanceForm.protocol} onInput={(event): void => props.onProtocolChange(event.currentTarget.value as InstanceForm["protocol"])}>
<option value="http">http</option>
<option value="https">https</option>
</select>
</label>
<label class={styles.field}>
<FieldLabelWithTooltip label="Access" tooltip={workspaceHomeFieldTooltips.access} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<select value={props.instanceForm.access} onInput={(event): void => props.onAccessChange(event.currentTarget.value as InstanceForm["access"])}>
<option value="local">local</option>
<option value="remote">remote</option>
</select>
</label>
<label class={styles.field}>
<FieldLabelWithTooltip label="Host" tooltip={workspaceHomeFieldTooltips.host} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<input type="text" value={props.instanceForm.host} onInput={(event): void => props.onHostChange(event.currentTarget.value)} placeholder="localhost or app.example.com" />
</label>
</>
);
type BootstrapModeStepProps = TooltipHandlers & {
modeForm: ModeForm;
structureForm: StructureForm;
usesCondensedBootstrapFlow: boolean;
selectedPersona: BootstrapPersona;
namePlaceholder: string;
onNameChange: (value: string) => void;
onProjectNameChange: (value: string) => void;
onTeamNameChange: (value: string) => void;
};
export const BootstrapModeStep = (props: BootstrapModeStepProps): JSX.Element => (
<>
<label class={styles.field}>
<FieldLabelWithTooltip label="Server name" tooltip={workspaceHomeFieldTooltips.serverName} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<input type="text" value={props.modeForm.name} required onInput={(event): void => props.onNameChange(event.currentTarget.value)} placeholder={props.namePlaceholder} />
</label>
<Show when={props.usesCondensedBootstrapFlow}>
<label class={styles.field}>
<FieldLabelWithTooltip label="Default Project" tooltip={workspaceHomeFieldTooltips.project} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<input type="text" value={props.structureForm.projectName} onInput={(event): void => props.onProjectNameChange(event.currentTarget.value)} placeholder="Project" />
</label>
</Show>
<Show when={props.selectedPersona === "team"}>
<label class={styles.field}>
<FieldLabelWithTooltip label="Team name" tooltip={workspaceHomeFieldTooltips.team} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<input type="text" value={props.structureForm.teamName} onInput={(event): void => props.onTeamNameChange(event.currentTarget.value)} placeholder="Core Team" />
</label>
</Show>
</>
);
type BootstrapAdminStepProps = {
adminForm: AdminForm;
onDisplayNameChange: (value: string) => void;
onEmailChange: (value: string) => void;
onPasswordChange: (value: string) => void;
};
export const BootstrapAdminStep = (props: BootstrapAdminStepProps): JSX.Element => (
<>
<label class={styles.field}>
<span class={styles.fieldLabel}>Display name</span>
<input type="text" value={props.adminForm.displayName} onInput={(event): void => props.onDisplayNameChange(event.currentTarget.value)} placeholder="Admin" />
</label>
<label class={styles.field}>
<span class={styles.fieldLabel}>Email</span>
<input type="email" value={props.adminForm.email} onInput={(event): void => props.onEmailChange(event.currentTarget.value)} placeholder="admin@example.com" />
</label>
<label class={styles.field}>
<span class={styles.fieldLabel}>Password</span>
<input type="password" value={props.adminForm.password} onInput={(event): void => props.onPasswordChange(event.currentTarget.value)} placeholder="Create a strong password" />
<small class={styles.fieldHelp}>Use at least 12 characters with uppercase, lowercase, numbers, and symbols.</small>
</label>
</>
);
type BootstrapStructureStepProps = TooltipHandlers & {
mode: ModeForm["mode"];
structureForm: StructureForm;
onDepartmentNameChange: (value: string) => void;
onTeamNameChange: (value: string) => void;
onProjectNameChange: (value: string) => void;
};
export const BootstrapStructureStep = (props: BootstrapStructureStepProps): JSX.Element => (
<>
<label class={styles.field}>
<FieldLabelWithTooltip label="Department" tooltip={workspaceHomeFieldTooltips.department} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<input type="text" value={props.structureForm.departmentName} disabled={props.mode === "personal"} onInput={(event): void => props.onDepartmentNameChange(event.currentTarget.value)} placeholder={organizationalStructureDefaults.departmentName} />
</label>
<label class={styles.field}>
<FieldLabelWithTooltip label="Team" tooltip={workspaceHomeFieldTooltips.team} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<input type="text" value={props.structureForm.teamName} disabled={props.mode === "personal"} onInput={(event): void => props.onTeamNameChange(event.currentTarget.value)} placeholder={organizationalStructureDefaults.teamName} />
</label>
<label class={styles.field}>
<FieldLabelWithTooltip label="Project" tooltip={workspaceHomeFieldTooltips.project} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<input type="text" value={props.structureForm.projectName} onInput={(event): void => props.onProjectNameChange(event.currentTarget.value)} placeholder="Moku" />
</label>
</>
);
@@ -0,0 +1,107 @@
import { type JSX } from "solid-js";
import { useAppShellData } from "../../app-shell/data/app-shell.context";
import { useBootstrapWizard } from "./BootstrapWizard.hook";
import { BootstrapWizardDialog } from "./BootstrapWizardDialog";
export const BootstrapWizard = (): JSX.Element => {
const appShellData = useAppShellData();
const {
instanceForm,
setInstanceForm,
modeForm,
setModeForm,
adminForm,
setAdminForm,
structureForm,
setStructureForm,
selectedPersona,
hasChosenPersona,
stepState,
isBootstrapStateResolved,
isWizardOpen,
setIsWizardOpen,
setIsFinishingBootstrapFlow,
fieldTooltip,
materializationState,
isMaterializationInFlight,
hasMaterializationFailed,
showBootstrapFinishingState,
materializationStatusLabel,
materializationMessage,
selectedPersonaIsAvailable,
usesCondensedBootstrapFlow,
activeWizardSteps,
bootstrapNamePlaceholder,
bootstrapStepCount,
currentStep,
currentWizardStepIndex,
wizardProgressFillWidth,
currentStepState,
isFirstStep,
canDismissWizard,
handleCurrentStepSubmit,
applyPersonaSelection,
statusLabel,
showFieldTooltip,
hideFieldTooltip,
stepStatusLabel,
navigateBack,
navigateToVisibleStep,
} = useBootstrapWizard(appShellData);
return (
<BootstrapWizardDialog
isOpen={isBootstrapStateResolved() && isWizardOpen()}
currentStep={currentStep()}
currentStepState={currentStepState()}
activeWizardSteps={activeWizardSteps()}
currentWizardStepIndex={currentWizardStepIndex()}
stepState={stepState}
bootstrapStepCount={bootstrapStepCount()}
wizardProgressFillWidth={wizardProgressFillWidth()}
showBootstrapFinishingState={showBootstrapFinishingState()}
materializationState={materializationState()}
materializationStatusLabel={materializationStatusLabel()}
materializationMessage={materializationMessage()}
isMaterializationInFlight={isMaterializationInFlight()}
hasMaterializationFailed={hasMaterializationFailed()}
instanceForm={instanceForm}
modeForm={modeForm}
adminForm={adminForm}
structureForm={structureForm}
selectedPersona={selectedPersona()}
hasChosenPersona={hasChosenPersona()}
selectedPersonaIsAvailable={selectedPersonaIsAvailable()}
usesCondensedBootstrapFlow={usesCondensedBootstrapFlow()}
bootstrapNamePlaceholder={bootstrapNamePlaceholder()}
isFirstStep={isFirstStep()}
canDismissWizard={canDismissWizard()}
fieldTooltip={fieldTooltip()}
onClose={(): void => {
setIsWizardOpen(false);
}}
onCloseFinishingState={(): void => {
setIsFinishingBootstrapFlow(false);
setIsWizardOpen(false);
}}
onSubmit={handleCurrentStepSubmit}
onSelectPersona={applyPersonaSelection}
onProtocolChange={(value): void => setInstanceForm("protocol", value)}
onAccessChange={(value): void => setInstanceForm("access", value)}
onHostChange={(value): void => setInstanceForm("host", value)}
onModeNameChange={(value): void => setModeForm("name", value)}
onProjectNameChange={(value): void => setStructureForm("projectName", value)}
onTeamNameChange={(value): void => setStructureForm("teamName", value)}
onAdminDisplayNameChange={(value): void => setAdminForm("displayName", value)}
onAdminEmailChange={(value): void => setAdminForm("email", value)}
onAdminPasswordChange={(value): void => setAdminForm("password", value)}
onDepartmentNameChange={(value): void => setStructureForm("departmentName", value)}
onShowTooltip={showFieldTooltip}
onHideTooltip={hideFieldTooltip}
statusLabel={statusLabel}
stepStatusLabel={stepStatusLabel}
onNavigateBack={navigateBack}
onSelectStep={navigateToVisibleStep}
/>
);
};
@@ -0,0 +1,234 @@
// Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizardDialog.tsx
import { Show, type JSX } from "solid-js";
import { Portal } from "solid-js/web";
import {
type AdminForm,
type BootstrapPersona,
bootstrapPersonaDefinitions,
type BootstrapStepDefinition,
type BootstrapStepKey,
type InstanceForm,
type ModeForm,
type StructureForm,
} from "./BootstrapWizard.data";
import { type BootstrapSubmissionState, type FieldTooltipState, type MaterializationState } from "./BootstrapWizard.hook";
import {
BootstrapAdminStep,
BootstrapFinishingState,
BootstrapInstanceStep,
BootstrapModeStep,
BootstrapPersonaStep,
BootstrapStructureStep,
BootstrapWizardProgress,
} from "./BootstrapWizard.parts";
import styles from "./BootstrapWizard.module.scss";
export type BootstrapWizardDialogProps = {
isOpen: boolean;
currentStep: BootstrapStepDefinition;
currentStepState: BootstrapSubmissionState;
activeWizardSteps: readonly BootstrapStepDefinition[];
currentWizardStepIndex: number;
stepState: Record<BootstrapStepKey, BootstrapSubmissionState>;
bootstrapStepCount: number;
wizardProgressFillWidth: string;
showBootstrapFinishingState: boolean;
materializationState: MaterializationState;
materializationStatusLabel: string;
materializationMessage: string;
isMaterializationInFlight: boolean;
hasMaterializationFailed: boolean;
instanceForm: InstanceForm;
modeForm: ModeForm;
adminForm: AdminForm;
structureForm: StructureForm;
selectedPersona: BootstrapPersona;
hasChosenPersona: boolean;
selectedPersonaIsAvailable: boolean;
usesCondensedBootstrapFlow: boolean;
bootstrapNamePlaceholder: string;
isFirstStep: boolean;
canDismissWizard: boolean;
fieldTooltip: FieldTooltipState | null;
onClose: () => void;
onCloseFinishingState: () => void;
onSubmit: JSX.EventHandlerUnion<HTMLFormElement, SubmitEvent>;
onSelectPersona: (persona: BootstrapPersona) => void;
onProtocolChange: (value: InstanceForm["protocol"]) => void;
onAccessChange: (value: InstanceForm["access"]) => void;
onHostChange: (value: string) => void;
onModeNameChange: (value: string) => void;
onProjectNameChange: (value: string) => void;
onTeamNameChange: (value: string) => void;
onAdminDisplayNameChange: (value: string) => void;
onAdminEmailChange: (value: string) => void;
onAdminPasswordChange: (value: string) => void;
onDepartmentNameChange: (value: string) => void;
onShowTooltip: (target: HTMLElement, text: string) => void;
onHideTooltip: () => void;
statusLabel: (state: BootstrapSubmissionState) => string;
stepStatusLabel: (step: BootstrapStepDefinition) => string;
onNavigateBack: () => void;
onSelectStep: (index: number) => void;
};
export const BootstrapWizardDialog = (props: BootstrapWizardDialogProps): JSX.Element => (
<Show when={props.isOpen}>
<Portal>
<div class={styles.wizardLayer} data-ui="bootstrap-wizard" data-step={props.currentStep.id}>
<div class={styles.wizardBackdrop} aria-hidden="true" />
<section class={styles.wizardPanel} role="dialog" aria-modal="true" aria-labelledby="bootstrap-wizard-title" data-slot="bootstrap-wizard-panel">
<header class={styles.wizardHeader} data-slot="bootstrap-wizard-header">
<div class={styles.wizardHeaderCopy}>
<h2 id="bootstrap-wizard-title" class={styles.wizardTitle}>
Bootstrap Server
</h2>
</div>
<Show when={props.canDismissWizard}>
<button type="button" class={styles.wizardCloseButton} onClick={props.onClose}>
Close
</button>
</Show>
</header>
<Show
when={!props.showBootstrapFinishingState}
fallback={
<BootstrapFinishingState
materializationState={props.materializationState}
statusLabel={props.materializationStatusLabel}
message={props.materializationMessage}
isInFlight={props.isMaterializationInFlight}
hasFailed={props.hasMaterializationFailed}
onClose={props.onCloseFinishingState}
/>
}
>
<div class={styles.wizardBody}>
<Show when={props.currentStep.id !== "persona"}>
<BootstrapWizardProgress
steps={props.activeWizardSteps}
currentStepId={props.currentStep.id}
currentWizardStepIndex={props.currentWizardStepIndex}
stepState={props.stepState}
bootstrapStepCount={props.bootstrapStepCount}
wizardProgressFillWidth={props.wizardProgressFillWidth}
stepStatusLabel={props.stepStatusLabel}
onSelectStep={props.onSelectStep}
/>
</Show>
<div class={styles.wizardStepPanel} data-slot="bootstrap-wizard-step-panel">
<Show when={props.currentStep.id !== "persona" || props.statusLabel(props.currentStepState)}>
<div class={styles.sectionHeader}>
<Show when={props.currentStep.id !== "persona"}>
<div>
<span class={styles.wizardStepEyebrow}>{`Step ${props.currentWizardStepIndex + 1} of ${props.bootstrapStepCount}`}</span>
</div>
</Show>
<Show when={props.statusLabel(props.currentStepState)}>
<div class={styles.statusBadge} data-status={props.currentStepState.status}>
{props.statusLabel(props.currentStepState)}
</div>
</Show>
</div>
</Show>
<form class={styles.form} onSubmit={props.onSubmit}>
<Show when={props.currentStep.id === "persona"}>
<BootstrapPersonaStep
personas={bootstrapPersonaDefinitions}
hasChosenPersona={props.hasChosenPersona}
selectedPersona={props.selectedPersona}
selectedPersonaIsAvailable={props.selectedPersonaIsAvailable}
onSelectPersona={props.onSelectPersona}
/>
</Show>
<Show when={props.currentStep.id === "instance"}>
<BootstrapInstanceStep
instanceForm={props.instanceForm}
onProtocolChange={props.onProtocolChange}
onAccessChange={props.onAccessChange}
onHostChange={props.onHostChange}
onShowTooltip={props.onShowTooltip}
onHideTooltip={props.onHideTooltip}
/>
</Show>
<Show when={props.currentStep.id === "mode"}>
<BootstrapModeStep
modeForm={props.modeForm}
structureForm={props.structureForm}
usesCondensedBootstrapFlow={props.usesCondensedBootstrapFlow}
selectedPersona={props.selectedPersona}
namePlaceholder={props.bootstrapNamePlaceholder}
onNameChange={props.onModeNameChange}
onProjectNameChange={props.onProjectNameChange}
onTeamNameChange={props.onTeamNameChange}
onShowTooltip={props.onShowTooltip}
onHideTooltip={props.onHideTooltip}
/>
</Show>
<Show when={props.currentStep.id === "admin"}>
<BootstrapAdminStep
adminForm={props.adminForm}
onDisplayNameChange={props.onAdminDisplayNameChange}
onEmailChange={props.onAdminEmailChange}
onPasswordChange={props.onAdminPasswordChange}
/>
</Show>
<Show when={props.currentStep.id === "structure"}>
<BootstrapStructureStep
mode={props.modeForm.mode}
structureForm={props.structureForm}
onDepartmentNameChange={props.onDepartmentNameChange}
onTeamNameChange={props.onTeamNameChange}
onProjectNameChange={props.onProjectNameChange}
onShowTooltip={props.onShowTooltip}
onHideTooltip={props.onHideTooltip}
/>
</Show>
<Show when={props.currentStep.id !== "persona"}>
<div class={styles.wizardFormActions}>
<button type="button" class={styles.secondaryButton} disabled={props.isFirstStep} onClick={props.onNavigateBack}>
Back
</button>
<button type="submit" class={styles.primaryButton} disabled={props.currentStepState.status === "submitting"}>
{props.currentStep.buttonLabel}
</button>
</div>
</Show>
</form>
<Show when={props.currentStepState.error}>
<p class={styles.errorText}>{props.currentStepState.error}</p>
</Show>
</div>
</div>
</Show>
</section>
<Show when={props.fieldTooltip}>
{(tooltip): JSX.Element => (
<div
class={styles.fieldTooltip}
data-placement={tooltip().placement}
style={{
left: `${tooltip().left}px`,
top: `${tooltip().top}px`,
}}
>
<div class={styles.fieldTooltipBubble}>{tooltip().text}</div>
</div>
)}
</Show>
</div>
</Portal>
</Show>
);
@@ -0,0 +1,81 @@
import { resolveAPIBase } from "../../../lib/api";
import type { BootstrapStepKey } from "./BootstrapWizard.data";
const readBootstrapResponseBody = async (response: Response): Promise<unknown> => {
const raw = await response.text();
if (!raw.trim()) {
return null;
}
try {
return JSON.parse(raw);
} catch {
return raw;
}
};
const readBootstrapResponseError = (step: BootstrapStepKey, data: unknown): string => {
const fallback = `Bootstrap ${step} request failed.`;
if (typeof data === "string") {
const message = data.trim();
return message || fallback;
}
if (!data || typeof data !== "object") {
return fallback;
}
const record = data as {
error?: string;
message?: string;
requestId?: string;
};
const message = typeof record.message === "string" ? record.message.trim() : "";
const errorCode = typeof record.error === "string" ? record.error.trim() : "";
const requestId = typeof record.requestId === "string" ? record.requestId.trim() : "";
if (!message && !errorCode && !requestId) {
return fallback;
}
const details: string[] = [];
if (errorCode) {
details.push(`code: ${errorCode}`);
}
if (requestId) {
details.push(`request: ${requestId}`);
}
if (message && details.length > 0) {
return `${message} (${details.join(", ")})`;
}
if (message) {
return message;
}
return `${fallback} (${details.join(", ")})`;
};
export const submitBootstrapStepRequest = async (
step: BootstrapStepKey,
payload: unknown,
): Promise<void> => {
const response = await fetch(`${resolveAPIBase()}/bootstrap/steps/${step}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(payload),
});
const data = await readBootstrapResponseBody(response);
if (!response.ok) {
throw new Error(readBootstrapResponseError(step, data));
}
};
@@ -1,190 +0,0 @@
.browserLayer {
display: none;
}
@include respond-down(mobile) {
.browserLayer {
position: fixed;
inset: 0;
display: grid;
z-index: calc(var(--z-popover, 20) + 2);
background: color-mix(in srgb, var(--color-canvas) 96%, black 4%);
}
.sheet {
min-height: 100dvh;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
background:
linear-gradient(180deg, color-mix(in srgb, var(--color-surface) 84%, transparent) 0%, transparent 8rem),
color-mix(in srgb, var(--color-canvas) 97%, black 3%);
overflow: clip;
}
.sheetHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: calc(var(--space-5) + env(safe-area-inset-top, 0px)) var(--space-4) var(--space-4);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 84%, transparent);
}
.headerActions {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.brandBlock {
min-width: 0;
display: grid;
gap: var(--space-1);
}
.sectionLabel {
@include text-caption;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.brandEyebrow {
@include text-caption;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.brandTitle {
@include text-title;
color: var(--color-text);
font-weight: var(--font-weight-semibold);
}
.brandContext {
@include text-caption;
color: var(--color-text-subtle);
}
.closeButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--control-size-md);
height: var(--control-size-md);
padding: 0;
border: 1px solid color-mix(in srgb, var(--color-border) 92%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--color-surface) 92%, transparent);
color: var(--color-text-subtle);
}
.createButton {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-height: var(--control-size-md);
padding: 0 var(--space-3);
border: 1px solid color-mix(in srgb, var(--color-border-strong, var(--color-border)) 82%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--color-surface) 94%, var(--color-canvas) 6%);
color: var(--color-text);
box-shadow: var(--shadow-soft);
font: inherit;
font-weight: var(--font-weight-semibold);
}
.sheetBody {
min-height: 0;
display: grid;
gap: var(--space-5);
padding: var(--space-5) var(--space-5) calc(var(--space-12) + var(--space-10) + env(safe-area-inset-bottom, 0px));
overflow: auto;
}
.sectionBlock {
display: grid;
gap: var(--space-2);
}
.treeList,
.treeListItem {
list-style: none;
margin: 0;
padding: 0;
}
.treeList {
display: grid;
gap: 0;
border-top: 1px solid color-mix(in srgb, var(--color-border) 92%, transparent);
}
.treeListNested {
display: grid;
gap: 0;
}
.treeRow {
min-width: 0;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
min-height: calc(var(--control-size-lg) + var(--space-3));
padding: var(--space-4) var(--space-2) var(--space-4) calc(var(--space-2) + (var(--tree-depth, 0) * var(--space-4)));
border: 0;
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 92%, transparent);
border-radius: 0;
background: transparent;
color: var(--color-text);
text-align: left;
}
.treeRowActive {
color: var(--color-text);
background: color-mix(in srgb, var(--color-surface) 72%, transparent);
}
.treeRowBranch {
font-weight: var(--font-weight-semibold);
}
.treeRowLead,
.treeRowTrail {
min-width: 0;
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.treeRowTrail {
flex: 0 0 auto;
color: var(--color-text-muted);
}
.treeLabel {
@include text-label;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.treeMeta {
@include text-caption;
min-width: 1rem;
text-align: right;
}
.treeChevron {
color: var(--color-text-subtle);
}
.treeListNested > .treeListItem > .treeRow {
min-height: calc(var(--control-size-lg) + var(--space-1));
}
}
@@ -1,247 +0,0 @@
import { For, Show, createSignal, type JSX } from "solid-js";
import { ChevronRight, Plus, X } from "../../../lib/icons";
import { useAppShellData } from "../data/app-shell.context";
import { createLongPressGesture } from "../createLongPressGesture";
import {
createWorkspaceStaticTarget,
createWorkspaceSurfaceTarget,
createWorkspaceTreeTarget,
getWorkspaceNodeIcon,
workspaceStaticItems,
type SidebarItem,
type WorkspaceContextMenuAction,
type WorkspaceContextMenuTarget,
type WorkspaceStaticItem,
type WorkspaceTreeNode,
} from "../data/shell.data";
import { WorkspaceMobileActionSheet } from "../WorkspaceMobileActionSheet/WorkspaceMobileActionSheet";
import styles from "./MobileWorkspaceBrowser.module.scss";
type MobileWorkspaceBrowserProps = {
open: boolean;
onClose: VoidFunction;
};
const TreeRow = (props: { node: WorkspaceTreeNode; depth?: number }): JSX.Element => {
const depth = props.depth ?? 0;
const Icon = getWorkspaceNodeIcon(props.node);
const hasChildren = (props.node.children?.length ?? 0) > 0;
return (
<button
classList={{
[styles.treeRow]: true,
[styles.treeRowActive]: props.node.active ?? false,
[styles.treeRowBranch]: hasChildren,
}}
type="button"
style={{ "--tree-depth": `${depth}` }}
data-slot="mobile-workspace-tree-row"
data-kind={props.node.kind}
data-item-type={props.node.kind === "item" ? props.node.itemType : undefined}
data-active={props.node.active ? "true" : "false"}
>
<span class={styles.treeRowLead}>
<Icon size={16} strokeWidth={2} />
<span class={styles.treeLabel}>{props.node.label}</span>
</span>
<span class={styles.treeRowTrail}>
<Show when={props.node.meta}>
<span class={styles.treeMeta}>{props.node.meta}</span>
</Show>
<Show when={hasChildren}>
<ChevronRight size={14} strokeWidth={2} class={styles.treeChevron} />
</Show>
</span>
</button>
);
};
const StaticRow = (props: { item: SidebarItem }): JSX.Element => {
const Icon = props.item.icon;
return (
<button classList={{ [styles.treeRow]: true, [styles.treeRowActive]: props.item.active ?? false }} type="button" style={{ "--tree-depth": "0" }} data-slot="mobile-workspace-static-row" data-active={props.item.active ? "true" : "false"}>
<span class={styles.treeRowLead}>
<Icon size={16} strokeWidth={2} />
<span class={styles.treeLabel}>{props.item.label}</span>
</span>
<span class={styles.treeRowTrail}>
<Show when={props.item.meta}>
<span class={styles.treeMeta}>{props.item.meta}</span>
</Show>
<ChevronRight size={14} strokeWidth={2} class={styles.treeChevron} />
</span>
</button>
);
};
const WorkspaceStaticRow = (props: {
item: WorkspaceStaticItem;
onOpenActionSheet: (target: WorkspaceContextMenuTarget) => void;
}): JSX.Element => {
const target = createWorkspaceStaticTarget(props.item);
const longPress = createLongPressGesture({
onLongPress: () => {
props.onOpenActionSheet(target);
},
});
return (
<li
class={styles.treeListItem}
data-slot="mobile-workspace-static-item"
data-target-kind={target.kind}
onContextMenu={(event): void => {
event.preventDefault();
props.onOpenActionSheet(target);
}}
{...longPress}
>
<StaticRow item={props.item} />
</li>
);
};
const WorkspaceTreeBranch = (props: {
nodes: readonly WorkspaceTreeNode[];
depth?: number;
onOpenActionSheet: (target: WorkspaceContextMenuTarget) => void;
}): JSX.Element => {
const depth = props.depth ?? 0;
return (
<For each={props.nodes}>
{(node): JSX.Element => {
const target = createWorkspaceTreeTarget(node);
const longPress = createLongPressGesture({
onLongPress: () => {
props.onOpenActionSheet(target);
},
});
return (
<li
class={styles.treeListItem}
data-slot="mobile-workspace-tree-item"
data-kind={node.kind}
data-item-type={node.kind === "item" ? node.itemType : undefined}
onContextMenu={(event): void => {
event.preventDefault();
props.onOpenActionSheet(target);
}}
{...longPress}
>
<TreeRow node={node} depth={depth} />
<Show when={node.children?.length}>
<ul class={styles.treeListNested}>
<WorkspaceTreeBranch nodes={node.children ?? []} depth={depth + 1} onOpenActionSheet={props.onOpenActionSheet} />
</ul>
</Show>
</li>
);
}}
</For>
);
};
export const MobileWorkspaceBrowser = (props: MobileWorkspaceBrowserProps): JSX.Element => {
const appShellData = useAppShellData();
const [actionSheetTarget, setActionSheetTarget] = createSignal<WorkspaceContextMenuTarget | null>(null);
const sectionNodes = () => appShellData.workspaceTree().filter((node) => (node.children?.length ?? 0) > 0);
const looseNodes = () => appShellData.workspaceTree().filter((node) => (node.children?.length ?? 0) === 0);
const workspaceTarget = () => createWorkspaceSurfaceTarget(appShellData.activeProject());
const openActionSheet = (target: WorkspaceContextMenuTarget): void => {
setActionSheetTarget(target);
};
const closeActionSheet = (): void => {
setActionSheetTarget(null);
};
const openWorkspaceActionSheet = (): void => {
openActionSheet(workspaceTarget());
};
const handleActionSelect = (_action: WorkspaceContextMenuAction, _target: WorkspaceContextMenuTarget): void => {
// Mobile first pass only establishes the action-sheet IA and long-press behavior.
};
const workspaceLongPress = createLongPressGesture({
onLongPress: openWorkspaceActionSheet,
});
return (
<Show when={props.open}>
<div class={styles.browserLayer} data-ui="mobile-workspace-browser">
<section class={styles.sheet} aria-label="Mobile workspace browser" data-slot="mobile-workspace-sheet">
<header class={styles.sheetHeader} data-slot="mobile-workspace-header">
<div
class={styles.brandBlock}
data-slot="mobile-workspace-brand"
onContextMenu={(event): void => {
event.preventDefault();
openWorkspaceActionSheet();
}}
{...workspaceLongPress}
>
{/* Long-pressing the browser header exposes workspace-level actions on mobile. */}
<span class={styles.brandEyebrow}>Moku Work</span>
<strong class={styles.brandTitle}>{appShellData.activeProject().name}</strong>
<span class={styles.brandContext}>{appShellData.activeServer().name}</span>
</div>
<div class={styles.headerActions} data-slot="mobile-workspace-header-actions">
<button
class={styles.createButton}
type="button"
aria-label="Create"
data-slot="mobile-workspace-create"
onClick={openWorkspaceActionSheet}
>
<Plus size={16} strokeWidth={2.25} />
<span>Create</span>
</button>
<button class={styles.closeButton} type="button" aria-label="Close workspace browser" data-slot="mobile-workspace-close" onClick={props.onClose}>
<X size={18} strokeWidth={2} />
</button>
</div>
</header>
<div class={styles.sheetBody} data-slot="mobile-workspace-body">
<section class={styles.sectionBlock} data-slot="mobile-workspace-section" data-section-id="workspace">
<span class={styles.sectionLabel}>Workspace</span>
<ul class={styles.treeList} data-slot="mobile-workspace-list" data-section-id="workspace">
<For each={workspaceStaticItems}>
{(item): JSX.Element => <WorkspaceStaticRow item={item} onOpenActionSheet={openActionSheet} />}
</For>
</ul>
</section>
<section class={styles.sectionBlock} data-slot="mobile-workspace-section" data-section-id="items">
<span class={styles.sectionLabel}>Items</span>
<ul class={styles.treeList} data-slot="mobile-workspace-list" data-section-id="items">
<WorkspaceTreeBranch nodes={sectionNodes()} onOpenActionSheet={openActionSheet} />
</ul>
</section>
<Show when={looseNodes().length > 0}>
<section class={styles.sectionBlock} data-slot="mobile-workspace-section" data-section-id="more">
<span class={styles.sectionLabel}>More</span>
<ul class={styles.treeList} data-slot="mobile-workspace-list" data-section-id="more">
<WorkspaceTreeBranch nodes={looseNodes()} onOpenActionSheet={openActionSheet} />
</ul>
</section>
</Show>
</div>
</section>
<WorkspaceMobileActionSheet
target={actionSheetTarget()}
onClose={closeActionSheet}
onSelect={handleActionSelect}
/>
</div>
</Show>
);
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,375 +0,0 @@
// Path: Frontend/src/components/shell/data/app-shell.context.tsx
import {
createContext,
createMemo,
createSignal,
onMount,
useContext,
type Accessor,
type JSX,
} from "solid-js";
import { Folder } from "../../../lib/icons";
import { resolveAPIBase } from "../../../lib/api";
import {
activeDepartment as fallbackActiveDepartment,
activeProject as fallbackActiveProject,
activeServer as fallbackActiveServer,
activeUserProfile as fallbackActiveUserProfile,
departmentItems as fallbackDepartmentItems,
organizationAdminDockActions,
personalDockActions,
projectItems as fallbackProjectItems,
railItems as fallbackRailItems,
workspaceTree as fallbackWorkspaceTree,
type ActiveDepartment,
type ActiveProject,
type ActiveServer,
type ActiveUserProfile,
type DepartmentItem,
type ProjectItem,
type RailItem,
type WorkspaceTreeNode,
} from "./shell.data";
type AppShellInstallation = {
id: string;
name: string;
mode: "personal" | "organizational" | string;
access: string;
protocol: string;
host: string;
isBootstrapped: boolean;
materializationStatus: "not_started" | "pending" | "running" | "succeeded" | "failed" | string;
materializationError?: string;
};
type AppShellAdmin = {
id: string;
email: string;
displayName: string;
isInstanceAdmin: boolean;
homeTitle: string;
};
type AppShellOrganization = {
id: string;
name: string;
slug: string;
};
type AppShellDepartment = {
id: string;
organizationId: string;
name: string;
slug: string;
};
type AppShellTeam = {
id: string;
organizationId: string;
departmentId?: string;
name: string;
slug: string;
};
type AppShellProject = {
id: string;
organizationId: string;
departmentId?: string;
teamId?: string;
name: string;
slug: string;
};
type AppShellWorkspace = {
id: string;
organizationId: string;
name: string;
slug: string;
kind: "organization" | "department" | "team" | "project" | string;
departmentId?: string;
teamId?: string;
projectId?: string;
};
type AppShellPayload = {
installation?: AppShellInstallation;
admin?: AppShellAdmin;
organizations: AppShellOrganization[];
departments: AppShellDepartment[];
teams: AppShellTeam[];
projects: AppShellProject[];
workspaces: AppShellWorkspace[];
};
const normalizeInstallation = (
installation: AppShellInstallation | null | undefined,
): AppShellInstallation | undefined => {
if (!installation) {
return undefined;
}
const materializationStatus = installation.materializationStatus?.trim()
? installation.materializationStatus
: installation.isBootstrapped
? "succeeded"
: "not_started";
const materializationError = installation.materializationError?.trim() || undefined;
return {
...installation,
materializationStatus,
materializationError,
};
};
const normalizeAppShellPayload = (payload: AppShellPayload | null | undefined): AppShellPayload => ({
installation: normalizeInstallation(payload?.installation),
admin: payload?.admin,
organizations: Array.isArray(payload?.organizations) ? payload.organizations : [],
departments: Array.isArray(payload?.departments) ? payload.departments : [],
teams: Array.isArray(payload?.teams) ? payload.teams : [],
projects: Array.isArray(payload?.projects) ? payload.projects : [],
workspaces: Array.isArray(payload?.workspaces) ? payload.workspaces : [],
});
type AppShellContextValue = {
status: Accessor<"idle" | "loading" | "success" | "error">;
error: Accessor<string>;
installation: Accessor<AppShellInstallation | undefined>;
railItems: Accessor<readonly RailItem[]>;
activeServer: Accessor<ActiveServer>;
activeProject: Accessor<ActiveProject>;
activeDepartment: Accessor<ActiveDepartment>;
projectItems: Accessor<readonly ProjectItem[]>;
departmentItems: Accessor<readonly DepartmentItem[]>;
workspaceTree: Accessor<readonly WorkspaceTreeNode[]>;
activeUserProfile: Accessor<ActiveUserProfile>;
reload: () => Promise<void>;
};
const AppShellContext = createContext<AppShellContextValue>();
const buildAbbreviation = (name: string, fallback: string): string => {
const parts = name
.trim()
.split(/\s+/)
.filter(Boolean);
if (parts.length === 0) {
return fallback;
}
const abbreviation = parts
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("");
return abbreviation || fallback;
};
const buildRailItems = (payload: AppShellPayload | null): readonly RailItem[] => {
if (!payload?.installation || payload.organizations.length === 0) {
return fallbackRailItems;
}
const kind = payload.installation.mode === "personal" ? "personal" : "organization";
const serverName = payload.installation.name || payload.organizations[0]?.name || payload.installation.host;
return payload.organizations.map((organization, index) => ({
id: organization.id,
label: serverName || organization.name,
abbreviation: buildAbbreviation(serverName || organization.name, kind === "personal" ? "P" : "O"),
kind,
active: index === 0,
}));
};
const buildActiveServer = (payload: AppShellPayload | null): ActiveServer => {
const installation = payload?.installation;
const organization = payload?.organizations[0];
if (!installation || !organization) {
return fallbackActiveServer;
}
const kind = installation.mode === "personal" ? "personal" : "organization";
const serverName = installation.name || organization.name || installation.host;
return {
id: installation.id,
name: serverName || fallbackActiveServer.name,
abbreviation: buildAbbreviation(serverName, kind === "personal" ? "P" : "O"),
kind,
connectedLabel: kind === "organization" ? `${payload?.teams.length ?? 0} connected` : undefined,
subtitle: kind === "personal" ? installation.host || payload?.admin?.homeTitle || "Personal home" : undefined,
dockActions: kind === "personal" ? personalDockActions : organizationAdminDockActions,
};
};
const buildProjectItems = (payload: AppShellPayload | null): readonly ProjectItem[] => {
if (!payload?.projects.length) {
return fallbackProjectItems;
}
return payload.projects.map((project, index) => ({
id: project.id,
name: project.name,
description: project.slug || "Persisted project workspace",
groupLabel: payload.departments.find((department) => department.id === project.departmentId)?.name || "Projects",
parentLabel:
payload.teams.find((team) => team.id === project.teamId)?.name ||
payload.departments.find((department) => department.id === project.departmentId)?.name ||
"Shared project",
meta: (() => {
const workspaceCount = payload.workspaces.filter((workspace) => workspace.projectId === project.id).length;
return workspaceCount > 0 ? `${workspaceCount} workspace${workspaceCount === 1 ? "" : "s"}` : undefined;
})(),
active: index === 0,
}));
};
const buildActiveProject = (payload: AppShellPayload | null): ActiveProject => {
const firstProject = payload?.projects[0];
if (!firstProject) {
return fallbackActiveProject;
}
return {
id: firstProject.id,
name: firstProject.name,
};
};
const buildDepartmentItems = (payload: AppShellPayload | null): readonly DepartmentItem[] => {
if (!payload?.departments.length) {
return fallbackDepartmentItems;
}
return payload.departments.map((department, index) => ({
id: department.id,
name: department.name,
teams: payload.teams
.filter((team) => team.departmentId === department.id)
.map((team) => team.name),
active: index === 0,
}));
};
const buildActiveDepartment = (payload: AppShellPayload | null): ActiveDepartment => {
const firstDepartment = payload?.departments[0];
if (!firstDepartment) {
return fallbackActiveDepartment;
}
const firstTeamName = payload?.teams.find((team) => team.departmentId === firstDepartment.id)?.name ?? "";
return {
id: firstDepartment.id,
name: firstDepartment.name,
teamName: firstTeamName,
};
};
const buildWorkspaceTree = (payload: AppShellPayload | null): readonly WorkspaceTreeNode[] => {
if (!payload?.projects.length) {
return fallbackWorkspaceTree;
}
// The workspace tree should represent items inside the current project, not the
// project container itself. We do not have project-contents hydration yet, so
// return an empty tree rather than showing the project root as a fake item.
return [];
};
const buildActiveUserProfile = (payload: AppShellPayload | null): ActiveUserProfile => {
if (!payload?.admin) {
return fallbackActiveUserProfile;
}
const organizationName = payload.installation?.name || payload.organizations[0]?.name || fallbackActiveServer.name;
const departmentName = payload.departments[0]?.name;
return {
name: payload.admin.displayName,
email: payload.admin.email,
roleLabel: payload.admin.isInstanceAdmin ? "Instance admin" : "Member",
contextLabel: departmentName ? `${organizationName}${departmentName}` : organizationName,
};
};
export const AppShellDataProvider = (props: { children: JSX.Element }): JSX.Element => {
const [status, setStatus] = createSignal<"idle" | "loading" | "success" | "error">("idle");
const [error, setError] = createSignal("");
const [payload, setPayload] = createSignal<AppShellPayload | null>(null);
const load = async (): Promise<void> => {
setStatus("loading");
setError("");
try {
const response = await fetch(`${resolveAPIBase()}/app-shell`, {
headers: {
Accept: "application/json",
},
});
const body = (await response.json()) as {
data?: AppShellPayload;
error?: { message?: string } | string;
message?: string;
};
const errorMessage =
typeof body.message === "string"
? body.message
: typeof body.error === "string"
? body.error
: body.error?.message;
if (!response.ok || !body.data) {
throw new Error(errorMessage || "Failed to load app shell state.");
}
setPayload(normalizeAppShellPayload(body.data));
setStatus("success");
} catch (loadError) {
setStatus("error");
setError(loadError instanceof Error ? loadError.message : "Failed to load app shell state.");
}
};
onMount(() => {
void load();
});
const value: AppShellContextValue = {
status,
error,
installation: createMemo(() => payload()?.installation),
railItems: createMemo(() => buildRailItems(payload())),
activeServer: createMemo(() => buildActiveServer(payload())),
activeProject: createMemo(() => buildActiveProject(payload())),
activeDepartment: createMemo(() => buildActiveDepartment(payload())),
projectItems: createMemo(() => buildProjectItems(payload())),
departmentItems: createMemo(() => buildDepartmentItems(payload())),
workspaceTree: createMemo(() => buildWorkspaceTree(payload())),
activeUserProfile: createMemo(() => buildActiveUserProfile(payload())),
reload: load,
};
return <AppShellContext.Provider value={value}>{props.children}</AppShellContext.Provider>;
};
export const useAppShellData = (): AppShellContextValue => {
const context = useContext(AppShellContext);
if (!context) {
throw new Error("useAppShellData must be used within AppShellDataProvider");
}
return context;
};
@@ -1,735 +0,0 @@
// Path: Frontend/src/components/shell/data/shell.data.ts
import type { Component } from "solid-js";
import {
Bell,
CircleHelp,
FileText,
Folder,
Home,
Keyboard,
LayoutGrid,
ListCollapse,
LogOut,
Repeat,
Search,
Settings,
Shield,
User,
} from "../../../lib/icons";
type ShellIconProps = {
class?: string;
size?: number;
strokeWidth?: number;
};
export type ShellIcon = Component<ShellIconProps>;
export type RailItem = {
id: string;
label: string;
abbreviation: string;
kind: "personal" | "organization";
active?: boolean;
};
export type ServerDockAction = {
id: string;
label: string;
icon: ShellIcon;
};
export type ActiveServer = {
id: string;
name: string;
abbreviation: string;
kind: "personal" | "organization";
connectedLabel?: string;
subtitle?: string;
dockActions: readonly ServerDockAction[];
};
export type ActiveProject = {
id: string;
name: string;
};
export type ActiveDepartment = {
id: string;
name: string;
teamName: string;
};
export type DepartmentItem = {
id: string;
name: string;
teams: readonly string[];
active?: boolean;
};
export type ProjectItem = {
id: string;
name: string;
description: string;
groupLabel?: string;
parentLabel?: string;
meta?: string;
active?: boolean;
};
export type ProjectMenuTarget =
| {
id: string;
label: string;
kind: "surface";
}
| {
id: string;
label: string;
kind: "folder";
}
| {
id: string;
label: string;
kind: "project";
};
export type ProjectContextMenuAction = {
id: string;
label: string;
tone?: "default" | "danger";
shortcut?: WorkspaceContextMenuShortcut;
children?: readonly ProjectContextMenuAction[];
};
export type ProjectContextMenuSection = {
id: string;
label?: string;
items: readonly ProjectContextMenuAction[];
};
export type SidebarItem = {
id: string;
label: string;
icon: ShellIcon;
active?: boolean;
meta?: string;
};
export type WorkspaceStaticKind = "workspace" | "home" | "settings";
// Keep this open-ended so future server-driven or plugin-provided item types do
// not require a frontend source edit before they can be represented safely.
export type WorkspaceItemTypeId = string;
export type WorkspaceStaticItem = SidebarItem & {
contextKind: WorkspaceStaticKind;
};
export type WorkspaceFolderNode = {
id: string;
path?: string;
label: string;
kind: "folder";
icon: ShellIcon;
active?: boolean;
meta?: string;
children?: readonly WorkspaceTreeNode[];
};
export type WorkspaceItemNode = {
id: string;
label: string;
kind: "item";
itemType: WorkspaceItemTypeId;
active?: boolean;
meta?: string;
children?: undefined;
};
export type WorkspaceTreeNode = WorkspaceFolderNode | WorkspaceItemNode;
export type WorkspaceItemTypeDefinition = {
id: WorkspaceItemTypeId;
label: string;
shortLabel: string;
icon: ShellIcon;
noun: string;
actionPrefix: string;
defaultCreateLabel: string;
includeInWorkspaceCreate?: boolean;
description?: string;
};
export type SidebarHeaderAction = {
id: string;
label: string;
icon: ShellIcon;
};
export type TopBarAction = {
id: string;
label: string;
icon: ShellIcon;
};
export type MobileBottomNavItem = {
id: string;
label: string;
icon: ShellIcon;
active?: boolean;
};
export type WorkspaceContextMenuTarget =
| {
id: string;
label: string;
kind: WorkspaceStaticKind;
}
| {
id: string;
label: string;
kind: "folder";
}
| {
id: string;
label: string;
kind: "item";
itemType: WorkspaceItemTypeId;
};
export type WorkspaceContextMenuAction = {
id: string;
label: string;
tone?: "default" | "danger";
shortcut?: WorkspaceContextMenuShortcut;
children?: readonly WorkspaceContextMenuAction[];
};
export type WorkspaceContextMenuShortcutModifier = "meta" | "alt" | "shift";
export type WorkspaceContextMenuShortcutKey = "b" | "c" | "d" | "delete" | "enter" | "f" | "m" | "r";
export type WorkspaceContextMenuShortcut = {
modifiers?: readonly WorkspaceContextMenuShortcutModifier[];
key: WorkspaceContextMenuShortcutKey;
};
export type WorkspaceContextMenuSection = {
id: string;
label?: string;
items: readonly WorkspaceContextMenuAction[];
};
export const firstPartyWorkspaceItemTypes: readonly WorkspaceItemTypeDefinition[] = [
{
id: "core.doc",
label: "Doc",
shortLabel: "Doc",
icon: FileText,
noun: "doc",
actionPrefix: "doc",
defaultCreateLabel: "New doc",
includeInWorkspaceCreate: true,
description: "Rich text documents and notes.",
},
{
id: "core.board.kanban",
label: "Kanban board",
shortLabel: "Board",
icon: LayoutGrid,
noun: "board",
actionPrefix: "board",
defaultCreateLabel: "New board",
includeInWorkspaceCreate: true,
description: "Default board-style workspace item.",
},
{
id: "core.board.list",
label: "List board",
shortLabel: "Board",
icon: LayoutGrid,
noun: "board",
actionPrefix: "list-board",
defaultCreateLabel: "New list board",
description: "Alternate first-party board view prepared for the future registry.",
},
] as const;
const workspaceItemTypeMap = new Map<WorkspaceItemTypeId, WorkspaceItemTypeDefinition>(
firstPartyWorkspaceItemTypes.map((definition) => [definition.id, definition]),
);
const createUnknownWorkspaceItemTypeDefinition = (
itemType: WorkspaceItemTypeId,
): WorkspaceItemTypeDefinition => ({
id: itemType,
label: "Item",
shortLabel: "Item",
icon: FileText,
noun: "item",
actionPrefix: "item",
defaultCreateLabel: "New item",
description: "Fallback definition for unknown or future workspace item types.",
});
export const getWorkspaceItemTypeDefinition = (itemType: WorkspaceItemTypeId): WorkspaceItemTypeDefinition => {
return workspaceItemTypeMap.get(itemType) ?? createUnknownWorkspaceItemTypeDefinition(itemType);
};
export const getWorkspaceNodeIcon = (node: WorkspaceTreeNode): ShellIcon =>
node.kind === "folder" ? node.icon : getWorkspaceItemTypeDefinition(node.itemType).icon;
const getWorkspaceCreateActions = (): readonly WorkspaceContextMenuAction[] => [
{ id: "new-folder", label: "New folder", shortcut: { modifiers: ["alt"], key: "f" } },
...firstPartyWorkspaceItemTypes
.filter((definition) => definition.includeInWorkspaceCreate)
.map((definition) => ({
id: `create-${definition.actionPrefix}`,
label: definition.defaultCreateLabel,
shortcut:
definition.id === "core.board.kanban"
? ({ modifiers: ["alt"], key: "b" } as const)
: definition.id === "core.doc"
? ({ modifiers: ["alt"], key: "d" } as const)
: undefined,
})),
];
export const getWorkspaceContextMenuEyebrow = (target: WorkspaceContextMenuTarget): string => {
switch (target.kind) {
case "workspace":
case "home":
return "Workspace";
case "settings":
return "Configuration";
case "folder":
return "Folder";
case "item":
return getWorkspaceItemTypeDefinition(target.itemType).shortLabel;
}
};
export const createWorkspaceSurfaceTarget = (workspace: ActiveProject): WorkspaceContextMenuTarget => ({
id: `workspace-${workspace.id}`,
label: workspace.name,
kind: "workspace",
});
export const createWorkspaceStaticTarget = (item: WorkspaceStaticItem): WorkspaceContextMenuTarget => ({
id: item.id,
label: item.label,
kind: item.contextKind,
});
export const createWorkspaceTreeTarget = (node: WorkspaceTreeNode): WorkspaceContextMenuTarget => ({
id: node.id,
label: node.label,
...(node.kind === "folder"
? { kind: "folder" as const }
: {
kind: "item" as const,
itemType: node.itemType,
}),
});
export type NotificationItem = {
id: string;
title: string;
contextLabel: string;
timeLabel: string;
unread?: boolean;
};
export type ProfileMenuAction = {
id: string;
label: string;
icon: ShellIcon;
tone?: "default" | "danger";
};
export type ProfileMenuSection = {
id: string;
items: readonly ProfileMenuAction[];
};
export type ActiveUserProfile = {
name: string;
email: string;
roleLabel: string;
contextLabel: string;
};
export const personalDockActions: readonly ServerDockAction[] = [
{ id: "account", label: "Account", icon: User },
{ id: "settings", label: "Settings", icon: Settings },
] as const;
export const organizationAdminDockActions: readonly ServerDockAction[] = [
{ id: "members", label: "Members", icon: User },
{ id: "server", label: "Server", icon: Settings },
] as const;
// Server shell scaffold data
export const railItems: readonly RailItem[] = [
{ id: "personal-server", label: "Personal Server Name", abbreviation: "P", kind: "personal" },
{ id: "organization-server", label: "Organization Name", abbreviation: "O", kind: "organization", active: true },
{ id: "design-review", label: "Design Review", abbreviation: "D", kind: "organization" },
] as const;
export const activeServer: ActiveServer = {
id: "organization-server",
name: "Organization Name",
abbreviation: "O",
kind: "organization",
connectedLabel: "12 connected",
dockActions: organizationAdminDockActions,
};
// Workspace framing scaffold data
export const activeProject: ActiveProject = {
id: "general",
name: "General",
};
export const activeDepartment: ActiveDepartment = {
id: "product",
name: "Product",
teamName: "Design Systems",
};
export const projectItems: readonly ProjectItem[] = [
{
id: "general",
name: "General",
description: "Default shared project",
groupLabel: "Shared space",
parentLabel: "Workspace home",
meta: "1 workspace",
active: true,
},
{
id: "operations",
name: "Operations",
description: "Cross-team planning and delivery",
groupLabel: "Team folders",
parentLabel: "Shared Services",
meta: "2 workspaces",
},
{
id: "hiring",
name: "Hiring",
description: "Candidate pipeline and interview loops",
groupLabel: "Team folders",
parentLabel: "People Ops",
meta: "1 workspace",
},
] as const;
export const departmentItems: readonly DepartmentItem[] = [
{ id: "product", name: "Product", teams: ["Design Systems", "Research Ops"], active: true },
{ id: "engineering", name: "Engineering", teams: ["Platform", "Realtime Collaboration"] },
{ id: "operations", name: "Operations", teams: ["Shared Services", "People Ops"] },
] as const;
// Sidebar and topbar scaffold data
// These static entries stay pinned in both desktop and mobile workspace navigation.
export const workspaceStaticItems: readonly WorkspaceStaticItem[] = [
{ id: "home", label: "Home", icon: Home, active: true, contextKind: "home" },
{ id: "workspace-settings", label: "Settings", icon: Settings, contextKind: "settings" },
] as const;
// Freeform workspace tree scaffold: folders are structural, while non-folder
// nodes already flow through the future-safe itemType registry seam.
export const workspaceTree: readonly WorkspaceTreeNode[] = [
{
id: "product-workspace",
label: "Product",
kind: "folder",
icon: Folder,
children: [
{ id: "roadmap-board", label: "Roadmap", kind: "item", itemType: "core.board.kanban", active: true },
{ id: "launch-brief", label: "Launch Brief", kind: "item", itemType: "core.doc" },
{
id: "research-folder",
label: "Research",
kind: "folder",
icon: Folder,
children: [
{ id: "interviews-doc", label: "Interviews", kind: "item", itemType: "core.doc" },
{ id: "signals-board", label: "Signals", kind: "item", itemType: "core.board.kanban", meta: "2" },
],
},
],
},
{
id: "design-folder",
label: "Design",
kind: "folder",
icon: Folder,
children: [
{ id: "system-doc", label: "Design System", kind: "item", itemType: "core.doc" },
{ id: "review-board", label: "Review Queue", kind: "item", itemType: "core.board.kanban" },
],
},
{ id: "general-notes", label: "General Notes", kind: "item", itemType: "core.doc" },
] as const;
export const workspaceSidebarHeaderActions: readonly SidebarHeaderAction[] = [
{ id: "search-workspace", label: "Search workspace", icon: Search },
{ id: "toggle-workspace-folders", label: "Collapse all folders", icon: ListCollapse },
] as const;
export const mobileBottomNavItems: readonly MobileBottomNavItem[] = [
{ id: "home", label: "Home", icon: Home, active: true },
{ id: "search", label: "Search", icon: Search },
{ id: "browse", label: "Browse", icon: Folder },
] as const;
// Initial context-menu IA scaffold. Behavior wiring can evolve later, but the
// target kinds and action grouping should stay shared across workspace surfaces.
export const getWorkspaceContextMenuSections = (
target: WorkspaceContextMenuTarget,
): readonly WorkspaceContextMenuSection[] => {
const createActions = getWorkspaceCreateActions();
const createSubmenuAction = {
id: "create",
label: "Create",
children: createActions,
} as const;
switch (target.kind) {
case "workspace":
return [
{
id: "create",
label: undefined,
items: [createSubmenuAction],
},
{
id: "workspace",
label: undefined,
items: [
{ id: "rename-workspace", label: "Rename workspace", shortcut: { key: "enter" } },
{ id: "copy-workspace-link", label: "Copy link", shortcut: { modifiers: ["meta"], key: "c" } },
],
},
] as const;
case "home":
return [
{
id: "create",
label: undefined,
items: [createSubmenuAction],
},
{
id: "workspace",
label: undefined,
items: [{ id: "open-home", label: "Open home", shortcut: { key: "enter" } }],
},
] as const;
case "settings":
return [
{
id: "settings",
label: undefined,
items: [
{ id: "open-settings", label: "Open settings", shortcut: { key: "enter" } },
{ id: "copy-settings-link", label: "Copy link", shortcut: { modifiers: ["meta"], key: "c" } },
],
},
] as const;
case "folder":
return [
{
id: "open",
items: [
{ id: "open-folder", label: "Open folder", shortcut: { key: "enter" } },
{ id: "rename-folder", label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } },
],
},
{
id: "create",
label: undefined,
items: [createSubmenuAction],
},
{
id: "organize",
label: undefined,
items: [
{ id: "duplicate-folder", label: "Duplicate", shortcut: { modifiers: ["meta"], key: "d" } },
{ id: "move-folder", label: "Move", shortcut: { modifiers: ["meta"], key: "m" } },
{ id: "delete-folder", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
],
},
] as const;
case "item": {
const definition = getWorkspaceItemTypeDefinition(target.itemType);
const actionPrefix = definition.actionPrefix;
const nounLabel = definition.noun;
return [
{
id: `${actionPrefix}-primary`,
items: [
{ id: `open-${actionPrefix}`, label: `Open ${nounLabel}`, shortcut: { key: "enter" } },
{ id: `rename-${actionPrefix}`, label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } },
],
},
{
id: "organize",
label: undefined,
items: [
{ id: `duplicate-${actionPrefix}`, label: "Duplicate", shortcut: { modifiers: ["meta"], key: "d" } },
{ id: `move-${actionPrefix}`, label: "Move", shortcut: { modifiers: ["meta"], key: "m" } },
{ id: `delete-${actionPrefix}`, label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
],
},
] as const;
}
}
};
const getProjectCreateActions = (): readonly ProjectContextMenuAction[] =>
[
{ id: "new-project", label: "New project" },
{ id: "new-folder", label: "New folder" },
] as const;
const getProjectFolderDangerActions = (): readonly ProjectContextMenuAction[] =>
[
{ id: "rename-folder", label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } },
{ id: "delete-folder", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
] as const;
export const createProjectSurfaceTarget = (label = "Projects"): ProjectMenuTarget => ({
id: "project-surface",
label,
kind: "surface",
});
export const createProjectFolderTarget = (id: string, label: string): ProjectMenuTarget => ({
id,
label,
kind: "folder",
});
export const createProjectTarget = (project: ProjectItem): ProjectMenuTarget => ({
id: project.id,
label: project.name,
kind: "project",
});
export const getProjectContextMenuEyebrow = (target: ProjectMenuTarget): string => {
switch (target.kind) {
case "surface":
return "Projects";
case "folder":
return "Folder";
case "project":
return "Project";
}
};
export const getProjectContextMenuSections = (target: ProjectMenuTarget): readonly ProjectContextMenuSection[] => {
const createActions = getProjectCreateActions();
switch (target.kind) {
case "surface":
return [
{
id: "create",
items: createActions,
},
] as const;
case "folder":
return [
{
id: "create",
items: createActions,
},
{
id: "organize",
items: getProjectFolderDangerActions(),
},
] as const;
case "project":
return [
{
id: "create",
items: createActions,
},
] as const;
}
};
export const topBarActions: readonly TopBarAction[] = [
{ id: "search", label: "Search", icon: Search },
] as const;
export const notificationItems: readonly NotificationItem[] = [
{
id: "comment-design-systems",
title: "New comment on Design Systems",
contextLabel: "Product • Review thread updated",
timeLabel: "2m ago",
unread: true,
},
{
id: "sprint-platform",
title: "Sprint updated in Platform",
contextLabel: "Engineering • Scope changed",
timeLabel: "15m ago",
unread: true,
},
{
id: "member-joined",
title: "New member joined Operations",
contextLabel: "Organization Name • Access granted",
timeLabel: "1h ago",
},
{
id: "daily-summary",
title: "Daily summary is ready",
contextLabel: "General • 8 updates across boards",
timeLabel: "Today, 8:00 AM",
},
] as const;
export const unreadNotificationCount = notificationItems.filter((item) => item.unread).length;
export const activeUserProfile: ActiveUserProfile = {
name: "Demo Account",
email: "demo@moku.work",
roleLabel: "Founder · Product",
contextLabel: "Organization Name • Design Systems",
};
export const profileMenuSections: readonly ProfileMenuSection[] = [
{
id: "account",
items: [
{ id: "profile", label: "Profile", icon: User },
{ id: "account-settings", label: "Account Settings", icon: Settings },
{ id: "notifications", label: "Notifications", icon: Bell },
{ id: "security", label: "Security", icon: Shield },
],
},
{
id: "preferences",
items: [
{ id: "keyboard-shortcuts", label: "Keyboard Shortcuts", icon: Keyboard },
{ id: "theme-preferences", label: "Theme Preferences", icon: Settings },
{ id: "help-support", label: "Help & Support", icon: CircleHelp },
],
},
{
id: "session",
items: [
{ id: "switch-account", label: "Switch Account", icon: Repeat },
{ id: "sign-out", label: "Sign Out", icon: LogOut, tone: "danger" },
],
},
] as const;
@@ -1,7 +1,7 @@
import { For, createEffect, createSignal, onCleanup, onMount, type JSX } from "solid-js"; import { For, createEffect, createSignal, onCleanup, onMount, type JSX } from "solid-js";
import { ChevronDown } from "../../../lib/icons"; import { ChevronDown } from "../../../lib/icons";
import { useAppShellData } from "../data/app-shell.context"; import { useAppShellData } from "../../app-shell/data/app-shell.context";
import { type DepartmentItem } from "../data/shell.data"; import { type DepartmentItem } from "../../app-shell/data/shell.data";
import styles from "./DepartmentSelector.module.scss"; import styles from "./DepartmentSelector.module.scss";
export const DepartmentSelector = (): JSX.Element => { export const DepartmentSelector = (): JSX.Element => {
@@ -1,6 +1,6 @@
import type { JSX } from "solid-js"; import type { JSX } from "solid-js";
import { Bell } from "../../../lib/icons"; import { Bell } from "../../../lib/icons";
import { unreadNotificationCount } from "../data/shell.data"; import { unreadNotificationCount } from "../../app-shell/data/shell.data";
import styles from "./NotificationsButton.module.scss"; import styles from "./NotificationsButton.module.scss";
type NotificationsButtonProps = { type NotificationsButtonProps = {
@@ -1,6 +1,6 @@
import { For, Show, type JSX } from "solid-js"; import { For, Show, type JSX } from "solid-js";
import { Bell, Settings } from "../../../lib/icons"; import { Bell, Settings } from "../../../lib/icons";
import { notificationItems, unreadNotificationCount } from "../data/shell.data"; import { notificationItems, unreadNotificationCount } from "../../app-shell/data/shell.data";
import styles from "./NotificationsMenu.module.scss"; import styles from "./NotificationsMenu.module.scss";
type NotificationsMenuProps = { type NotificationsMenuProps = {
@@ -1,7 +1,7 @@
import { For, type JSX } from "solid-js"; import { For, type JSX } from "solid-js";
import { User } from "../../../lib/icons"; import { User } from "../../../lib/icons";
import { useAppShellData } from "../data/app-shell.context"; import { useAppShellData } from "../../app-shell/data/app-shell.context";
import { profileMenuSections } from "../data/shell.data"; import { profileMenuSections } from "../../app-shell/data/shell.data";
import styles from "./ProfileMenu.module.scss"; import styles from "./ProfileMenu.module.scss";
type ProfileMenuProps = { type ProfileMenuProps = {
@@ -1,7 +1,7 @@
// Path: Frontend/src/components/shell/TopBar/ThemeToggle.tsx // Path: Frontend/src/components/top-bar/TopBar/ThemeToggle.tsx
import type { JSX } from "solid-js"; import type { JSX } from "solid-js";
import type { Theme } from "../../../theme/runtime"; import type { Theme } from "../../../helper/themeRuntime";
import { Moon, Sun } from "../../../lib/icons"; import { Moon, Sun } from "../../../lib/icons";
import styles from "./ThemeToggle.module.scss"; import styles from "./ThemeToggle.module.scss";
@@ -1,8 +1,8 @@
// Path: Frontend/src/components/shell/TopBar/TopBar.tsx // Path: Frontend/src/components/top-bar/TopBar/TopBar.tsx
import { For, type JSX } from "solid-js"; import { For, type JSX } from "solid-js";
import type { Theme } from "../../../theme/runtime"; import type { Theme } from "../../../helper/themeRuntime";
import { topBarActions } from "../data/shell.data"; import { topBarActions } from "../../app-shell/data/shell.data";
import { DepartmentSelector } from "../DepartmentSelector/DepartmentSelector"; import { DepartmentSelector } from "../DepartmentSelector/DepartmentSelector";
import { NotificationsNav } from "./NotificationsNav"; import { NotificationsNav } from "./NotificationsNav";
import { ThemeToggle } from "./ThemeToggle"; import { ThemeToggle } from "./ThemeToggle";
@@ -1,4 +1,4 @@
// Path: Frontend/src/components/shell/TopBar/UserNavButton.tsx // Path: Frontend/src/components/top-bar/TopBar/UserNavButton.tsx
import type { JSX } from "solid-js"; import type { JSX } from "solid-js";
import { User } from "../../../lib/icons"; import { User } from "../../../lib/icons";
@@ -0,0 +1,25 @@
/* Path: Frontend/src/components/workspace-home/WorkspaceHome.module.scss */
.viewport {
--workspace-content-max-width: var(--content-width-wide);
min-width: 0;
min-height: 0;
display: grid;
align-content: start;
gap: var(--space-4);
padding: var(--space-5) var(--space-6);
}
.title {
@include text-display;
font-family: var(--font-family-display);
max-width: 12ch;
}
@include respond-down(mobile) {
.viewport {
gap: var(--space-4);
padding: var(--space-4) var(--space-4) calc(var(--space-8) + env(safe-area-inset-bottom, 0px));
}
}
@@ -0,0 +1,15 @@
// Path: Frontend/src/components/workspace-home/WorkspaceHome.tsx
import { type JSX } from "solid-js";
import { useAppShellData } from "../app-shell/data/app-shell.context";
import styles from "./WorkspaceHome.module.scss";
export const WorkspaceHome = (): JSX.Element => {
const appShellData = useAppShellData();
return (
<main class={styles.viewport} data-ui="workspace-home">
<h1 class={styles.title}>{appShellData.activeServer().name}</h1>
</main>
);
};
@@ -1,725 +0,0 @@
// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx
import { For, Show, createEffect, createMemo, createSignal, onCleanup, type JSX } from "solid-js";
import { Portal } from "solid-js/web";
import { createStore } from "solid-js/store";
import { resolveAPIBase } from "../../../lib/api";
import { ChevronLeft, ChevronRight } from "../../../lib/icons";
import { useAppShellData } from "../../shell/data/app-shell.context";
import styles from "./WorkspaceHome.module.scss";
type BootstrapStepKey = "instance" | "mode" | "admin" | "structure";
type BootstrapStepDefinition = {
id: BootstrapStepKey;
title: string;
buttonLabel: string;
};
type BootstrapSubmissionState = {
status: "idle" | "submitting" | "success" | "error";
error: string;
};
type InstanceForm = {
protocol: "http" | "https";
access: "local" | "remote";
host: string;
};
type ModeForm = {
mode: "personal" | "organizational";
name: string;
};
type AdminForm = {
displayName: string;
email: string;
password: string;
};
type StructureForm = {
departmentName: string;
teamName: string;
projectName: string;
};
type MaterializationState = "not_started" | "pending" | "running" | "succeeded" | "failed";
const bootstrapStepDefinitions: readonly BootstrapStepDefinition[] = [
{
id: "instance",
title: "Instance shape",
buttonLabel: "Save and continue",
},
{
id: "mode",
title: "Server mode",
buttonLabel: "Save and continue",
},
{
id: "admin",
title: "Admin account",
buttonLabel: "Save and continue",
},
{
id: "structure",
title: "Initial structure",
buttonLabel: "Submit",
},
];
const defaultInstanceForm: InstanceForm = {
protocol: "http",
access: "local",
host: "localhost",
};
const defaultModeForm: ModeForm = {
mode: "personal",
name: "",
};
const defaultAdminForm: AdminForm = {
displayName: "Admin",
email: "admin@example.com",
password: "",
};
const personalStructureDefaults = {
departmentName: "Default",
teamName: "Personal",
};
const organizationalStructureDefaults = {
departmentName: "Department",
teamName: "Team",
};
const defaultStructureForm: StructureForm = {
...personalStructureDefaults,
projectName: "Project",
};
const initialSubmissionState = (): BootstrapSubmissionState => ({
status: "idle",
error: "",
});
const materializationPollIntervalMs = 2000;
const readResponseBody = async (response: Response): Promise<unknown> => {
const raw = await response.text();
if (!raw.trim()) {
return null;
}
try {
return JSON.parse(raw);
} catch {
return raw;
}
};
const readResponseError = (step: BootstrapStepKey, data: unknown): string => {
const fallback = `Bootstrap ${step} request failed.`;
if (typeof data === "string") {
const message = data.trim();
return message || fallback;
}
if (!data || typeof data !== "object") {
return fallback;
}
const record = data as {
error?: string;
message?: string;
requestId?: string;
};
const message = typeof record.message === "string" ? record.message.trim() : "";
const errorCode = typeof record.error === "string" ? record.error.trim() : "";
const requestId = typeof record.requestId === "string" ? record.requestId.trim() : "";
if (!message && !errorCode && !requestId) {
return fallback;
}
const details: string[] = [];
if (errorCode) {
details.push(`code: ${errorCode}`);
}
if (requestId) {
details.push(`request: ${requestId}`);
}
if (message && details.length > 0) {
return `${message} (${details.join(", ")})`;
}
if (message) {
return message;
}
return `${fallback} (${details.join(", ")})`;
};
type WorkspaceHomeProps = {
sidebarCollapsed: boolean;
onToggleSidebarCollapse: () => void;
};
export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
const appShellData = useAppShellData();
const [instanceForm, setInstanceForm] = createStore<InstanceForm>({ ...defaultInstanceForm });
const [modeForm, setModeForm] = createStore<ModeForm>({ ...defaultModeForm });
const [adminForm, setAdminForm] = createStore<AdminForm>({ ...defaultAdminForm });
const [structureForm, setStructureForm] = createStore<StructureForm>({ ...defaultStructureForm });
const [stepState, setStepState] = createStore<Record<BootstrapStepKey, BootstrapSubmissionState>>({
instance: initialSubmissionState(),
mode: initialSubmissionState(),
admin: initialSubmissionState(),
structure: initialSubmissionState(),
});
const [isBootstrapStateResolved, setIsBootstrapStateResolved] = createSignal(false);
const [isBootstrapComplete, setIsBootstrapComplete] = createSignal(false);
const [isWizardOpen, setIsWizardOpen] = createSignal(false);
const [currentStepIndex, setCurrentStepIndex] = createSignal(0);
const installation = createMemo(() => appShellData.installation());
const materializationState = createMemo<MaterializationState>(() => {
const status = installation()?.materializationStatus;
switch (status) {
case "pending":
case "running":
case "failed":
case "succeeded":
case "not_started":
return status;
default:
return installation()?.isBootstrapped ? "succeeded" : "not_started";
}
});
const isBootstrapPersisted = createMemo(() => installation()?.isBootstrapped ?? false);
const isMaterializationInFlight = createMemo(
() => materializationState() === "pending" || materializationState() === "running",
);
const hasMaterializationFailed = createMemo(() => materializationState() === "failed");
const materializationStatusLabel = createMemo(() => {
switch (materializationState()) {
case "pending":
return "Materialization queued";
case "running":
return "Materialization running";
case "failed":
return "Materialization failed";
case "succeeded":
return "Ready";
default:
return "Not started";
}
});
const materializationMessage = createMemo(() => {
if (isMaterializationInFlight()) {
return "Your bootstrap is saved. The worker is still creating the POSIX skeleton and rebuilding the app shell index.";
}
if (hasMaterializationFailed()) {
return installation()?.materializationError || "Bootstrap saved, but background materialization did not finish cleanly.";
}
return "";
});
createEffect(() => {
if (modeForm.mode === "personal") {
setStructureForm("departmentName", personalStructureDefaults.departmentName);
setStructureForm("teamName", personalStructureDefaults.teamName);
return;
}
if (structureForm.departmentName === personalStructureDefaults.departmentName) {
setStructureForm("departmentName", organizationalStructureDefaults.departmentName);
}
if (structureForm.teamName === personalStructureDefaults.teamName) {
setStructureForm("teamName", organizationalStructureDefaults.teamName);
}
});
createEffect(() => {
const shellStatus = appShellData.status();
if (shellStatus === "idle" || shellStatus === "loading") {
return;
}
if (shellStatus !== "success") {
return;
}
if (!isBootstrapPersisted()) {
resetWizardState();
}
setIsBootstrapComplete(isBootstrapPersisted() && !isMaterializationInFlight());
setIsWizardOpen(!isBootstrapPersisted());
setIsBootstrapStateResolved(true);
});
createEffect(() => {
if (!isBootstrapPersisted() || !isMaterializationInFlight()) {
return;
}
let cancelled = false;
let timeoutId: number | undefined;
const scheduleReload = (): void => {
timeoutId = window.setTimeout(async () => {
if (cancelled) {
return;
}
// The final bootstrap step only persists relational state. Poll while the
// worker is materializing the POSIX skeleton so the page can transition from
// queued/running to ready/failed without a manual refresh.
await appShellData.reload();
if (!cancelled && isBootstrapPersisted() && isMaterializationInFlight()) {
scheduleReload();
}
}, materializationPollIntervalMs);
};
scheduleReload();
onCleanup(() => {
cancelled = true;
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
});
});
const sidebarToggleLabel = (): string =>
props.sidebarCollapsed ? "Expand left workspace sidebar" : "Collapse left workspace sidebar";
const breadcrumb = (): string => `${appShellData.activeServer().name} / ${appShellData.activeProject().name} / Home`;
const apiBase = (): string => resolveAPIBase();
const bootstrapTargetLabel = (): string =>
modeForm.mode === "personal" ? "Personal server" : "Organization server";
const bootstrapNamePlaceholder = (): string =>
modeForm.mode === "personal" ? "Personal server name" : "Organization server name";
const currentStep = createMemo<BootstrapStepDefinition>(
() => bootstrapStepDefinitions[currentStepIndex()] ?? bootstrapStepDefinitions[0]!,
);
const currentStepState = createMemo<BootstrapSubmissionState>(() => stepState[currentStep().id]);
const isFirstStep = (): boolean => currentStepIndex() === 0;
const isLastStep = (): boolean => currentStepIndex() === bootstrapStepDefinitions.length - 1;
const canDismissWizard = (): boolean => isBootstrapPersisted();
const resetWizardState = (): void => {
setInstanceForm({ ...defaultInstanceForm });
setModeForm({ ...defaultModeForm });
setAdminForm({ ...defaultAdminForm });
setStructureForm({ ...defaultStructureForm });
setStepState({
instance: initialSubmissionState(),
mode: initialSubmissionState(),
admin: initialSubmissionState(),
structure: initialSubmissionState(),
});
setCurrentStepIndex(0);
};
const submitStep = async (step: BootstrapStepKey, payload: unknown): Promise<boolean> => {
setStepState(step, { status: "submitting", error: "" });
try {
const response = await fetch(`${apiBase()}/bootstrap/steps/${step}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(payload),
});
const data = await readResponseBody(response);
if (!response.ok) {
throw new Error(readResponseError(step, data));
}
setStepState(step, {
status: "success",
error: "",
});
return true;
} catch (error) {
setStepState(step, {
status: "error",
error: error instanceof Error ? error.message : `Bootstrap ${step} request failed.`,
});
return false;
}
};
const payloadForStep = (step: BootstrapStepKey): unknown => {
switch (step) {
case "instance":
return instanceForm;
case "mode":
return modeForm;
case "admin":
return adminForm;
case "structure":
return structureForm;
}
};
const submitCurrentStep = async (): Promise<void> => {
const step = currentStep().id;
const didSucceed = await submitStep(step, payloadForStep(step));
if (!didSucceed) {
return;
}
if (isLastStep()) {
await appShellData.reload();
setIsBootstrapComplete(isBootstrapPersisted() && !isMaterializationInFlight());
setIsWizardOpen(!isBootstrapPersisted());
setIsBootstrapStateResolved(true);
return;
}
setCurrentStepIndex((index) => Math.min(index + 1, bootstrapStepDefinitions.length - 1));
};
const handleCurrentStepSubmit: JSX.EventHandler<HTMLFormElement, SubmitEvent> = (event): void => {
event.preventDefault();
void submitCurrentStep();
};
const statusLabel = (state: BootstrapSubmissionState): string => {
switch (state.status) {
case "submitting":
return "Sending";
case "success":
return "Saved";
case "error":
return "Request failed";
default:
return "Ready";
}
};
const stepStatusLabel = (step: BootstrapStepDefinition): string => {
const state = stepState[step.id];
if (state.status === "success") {
return "Done";
}
if (state.status === "error") {
return "Needs retry";
}
return "";
};
return (
<>
<main class={styles.viewport} data-ui="workspace-home">
<div class={styles.workspaceTopBar} data-slot="workspace-home-top-bar">
<div class={styles.workspaceTopBarStart} data-slot="workspace-home-top-bar-start">
<button
type="button"
class={styles.workspaceCollapseButton}
aria-label={sidebarToggleLabel()}
title={sidebarToggleLabel()}
data-slot="workspace-home-sidebar-toggle"
onClick={props.onToggleSidebarCollapse}
>
{props.sidebarCollapsed ? <ChevronRight size={16} strokeWidth={2} /> : <ChevronLeft size={16} strokeWidth={2} />}
</button>
</div>
<div class={styles.workspaceTopBarCenter} data-slot="workspace-home-top-bar-center">
<span class={styles.workspaceBreadcrumb}>{breadcrumb()}</span>
</div>
<div class={styles.workspaceTopBarEnd} data-slot="workspace-home-top-bar-end" aria-hidden="true" />
</div>
<section class={styles.hero} data-slot="workspace-home-hero">
<h1 class={styles.title}>{isBootstrapPersisted() ? appShellData.activeServer().name : bootstrapTargetLabel()}</h1>
<Show when={isBootstrapStateResolved() && isBootstrapPersisted() && materializationState() !== "succeeded"}>
<div class={styles.heroStatus}>
<div class={styles.statusBadge} data-status={materializationState()}>
{materializationStatusLabel()}
</div>
<Show when={materializationMessage()}>
<p class={styles.heroStatusMessage} data-status={materializationState()}>
{materializationMessage()}
</p>
</Show>
</div>
</Show>
<Show when={isBootstrapStateResolved() && !isBootstrapComplete()}>
<div class={styles.heroActions}>
<button
type="button"
class={styles.primaryButton}
disabled={isBootstrapPersisted()}
onClick={(): void => {
setIsWizardOpen(true);
}}
>
{isBootstrapPersisted() ? "Bootstrap saved" : "Open bootstrap wizard"}
</button>
</div>
</Show>
</section>
</main>
<Show when={isBootstrapStateResolved() && isWizardOpen()}>
<Portal>
<div class={styles.wizardLayer} data-ui="bootstrap-wizard" data-step={currentStep().id}>
<div class={styles.wizardBackdrop} aria-hidden="true" />
<section class={styles.wizardPanel} role="dialog" aria-modal="true" aria-labelledby="bootstrap-wizard-title" data-slot="bootstrap-wizard-panel">
<header class={styles.wizardHeader} data-slot="bootstrap-wizard-header">
<div class={styles.wizardHeaderCopy}>
<h2 id="bootstrap-wizard-title" class={styles.wizardTitle}>
Bootstrap {bootstrapTargetLabel()}
</h2>
</div>
<Show when={canDismissWizard()}>
<button
type="button"
class={styles.wizardCloseButton}
onClick={(): void => {
setIsWizardOpen(false);
}}
>
Close
</button>
</Show>
</header>
<div class={styles.wizardBody}>
<aside class={styles.wizardSidebar} data-slot="bootstrap-wizard-sidebar">
<nav class={styles.wizardSteps} aria-label="Bootstrap steps">
<For each={bootstrapStepDefinitions}>
{(step, index): JSX.Element => (
<button
type="button"
class={styles.wizardStepButton}
data-active={step.id === currentStep().id ? "true" : "false"}
disabled={index() > currentStepIndex()}
onClick={(): void => {
if (index() <= currentStepIndex()) {
setCurrentStepIndex(index());
}
}}
>
<span class={styles.wizardStepIndex}>{index() + 1}</span>
<span class={styles.wizardStepCopy}>
<strong>{step.title}</strong>
<Show when={stepStatusLabel(step)}>
<small>{stepStatusLabel(step)}</small>
</Show>
</span>
</button>
)}
</For>
</nav>
</aside>
<div class={styles.wizardStepPanel} data-slot="bootstrap-wizard-step-panel">
<div class={styles.sectionHeader}>
<div>
<span class={styles.wizardStepEyebrow}>{`Step ${currentStepIndex() + 1} of ${bootstrapStepDefinitions.length}`}</span>
<h3 class={styles.sectionTitle}>{currentStep().title}</h3>
</div>
<div class={styles.statusBadge} data-status={currentStepState().status}>
{statusLabel(currentStepState())}
</div>
</div>
<form class={styles.form} onSubmit={handleCurrentStepSubmit}>
<Show when={currentStep().id === "instance"}>
<>
<label class={styles.field}>
<span class={styles.fieldLabel}>Protocol</span>
<select
value={instanceForm.protocol}
onInput={(event): void =>
setInstanceForm("protocol", event.currentTarget.value as InstanceForm["protocol"])
}
>
<option value="http">http</option>
<option value="https">https</option>
</select>
</label>
<label class={styles.field}>
<span class={styles.fieldLabel}>Access</span>
<select
value={instanceForm.access}
onInput={(event): void =>
setInstanceForm("access", event.currentTarget.value as InstanceForm["access"])
}
>
<option value="local">local</option>
<option value="remote">remote</option>
</select>
</label>
<label class={styles.field}>
<span class={styles.fieldLabel}>Host</span>
<input
type="text"
value={instanceForm.host}
onInput={(event): void => setInstanceForm("host", event.currentTarget.value)}
placeholder="localhost or app.example.com"
/>
</label>
</>
</Show>
<Show when={currentStep().id === "mode"}>
<>
<label class={styles.field}>
<span class={styles.fieldLabel}>Mode</span>
<select
value={modeForm.mode}
onInput={(event): void => setModeForm("mode", event.currentTarget.value as ModeForm["mode"])}
>
<option value="personal">personal</option>
<option value="organizational">organizational</option>
</select>
</label>
<label class={styles.field}>
<span class={styles.fieldLabel}>Server name</span>
<input
type="text"
value={modeForm.name}
required
onInput={(event): void => setModeForm("name", event.currentTarget.value)}
placeholder={bootstrapNamePlaceholder()}
/>
</label>
</>
</Show>
<Show when={currentStep().id === "admin"}>
<>
<label class={styles.field}>
<span class={styles.fieldLabel}>Display name</span>
<input
type="text"
value={adminForm.displayName}
onInput={(event): void => setAdminForm("displayName", event.currentTarget.value)}
placeholder="Admin"
/>
</label>
<label class={styles.field}>
<span class={styles.fieldLabel}>Email</span>
<input
type="email"
value={adminForm.email}
onInput={(event): void => setAdminForm("email", event.currentTarget.value)}
placeholder="admin@example.com"
/>
</label>
<label class={styles.field}>
<span class={styles.fieldLabel}>Password</span>
<input
type="password"
value={adminForm.password}
onInput={(event): void => setAdminForm("password", event.currentTarget.value)}
placeholder="Create a strong password"
/>
<small class={styles.fieldHelp}>
Use at least 12 characters with uppercase, lowercase, numbers, and symbols.
</small>
</label>
</>
</Show>
<Show when={currentStep().id === "structure"}>
<>
<label class={styles.field}>
<span class={styles.fieldLabel}>Department</span>
<input
type="text"
value={structureForm.departmentName}
disabled={modeForm.mode === "personal"}
onInput={(event): void => setStructureForm("departmentName", event.currentTarget.value)}
placeholder={organizationalStructureDefaults.departmentName}
/>
</label>
<label class={styles.field}>
<span class={styles.fieldLabel}>Team</span>
<input
type="text"
value={structureForm.teamName}
disabled={modeForm.mode === "personal"}
onInput={(event): void => setStructureForm("teamName", event.currentTarget.value)}
placeholder={organizationalStructureDefaults.teamName}
/>
</label>
<label class={styles.field}>
<span class={styles.fieldLabel}>Project</span>
<input
type="text"
value={structureForm.projectName}
onInput={(event): void => setStructureForm("projectName", event.currentTarget.value)}
placeholder="Moku"
/>
</label>
</>
</Show>
<div class={styles.wizardFormActions}>
<button
type="button"
class={styles.secondaryButton}
disabled={isFirstStep()}
onClick={(): void => {
setCurrentStepIndex((index) => Math.max(index - 1, 0));
}}
>
Back
</button>
<button
type="submit"
class={styles.primaryButton}
disabled={currentStepState().status === "submitting"}
>
{currentStep().buttonLabel}
</button>
</div>
</form>
<Show when={currentStepState().error}>
<p class={styles.errorText}>{currentStepState().error}</p>
</Show>
</div>
</div>
</section>
</div>
</Portal>
</Show>
</>
);
};
@@ -1,8 +1,8 @@
// Path: Frontend/src/components/shell/LeftRail/LeftRail.tsx // Path: Frontend/src/components/workspace-navigation/LeftRail/LeftRail.tsx
import { For, Show, type JSX } from "solid-js"; import { For, Show, type JSX } from "solid-js";
import { useAppShellData } from "../data/app-shell.context"; import { useAppShellData } from "../../app-shell/data/app-shell.context";
import { type RailItem } from "../data/shell.data"; import { type RailItem } from "../../app-shell/data/shell.data";
import styles from "./LeftRail.module.scss"; import styles from "./LeftRail.module.scss";
type RailEntryProps = { type RailEntryProps = {
@@ -0,0 +1,91 @@
// Path: Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.data.ts
import type { WorkspaceTreeNode } from "../../app-shell/data/shell.data";
export type MobileWorkspaceBrowserProps = {
open: boolean;
onClose: VoidFunction;
};
export type MobileWorkspaceDialogState =
| {
kind: "text";
title: string;
message: string;
confirmLabel: string;
initialValue: string;
onConfirm: (value: string) => void;
}
| {
kind: "confirm";
title: string;
message: string;
confirmLabel: string;
tone?: "danger";
onConfirm: () => void;
};
export type MobileMoveTargetState = {
kind: "folder" | "item";
id: string;
label: string;
};
export type MobileMoveDestination = {
id: string | null;
label: string;
depth: number;
meta?: string;
};
export const collectMoveDestinations = (
nodes: readonly WorkspaceTreeNode[],
movingTarget: MobileMoveTargetState,
depth = 0,
ancestorBlocked = false,
): MobileMoveDestination[] => {
const destinations: MobileMoveDestination[] = [];
for (const node of nodes) {
if (node.kind !== "folder") {
continue;
}
const isBlockedFolder = movingTarget.kind === "folder" && node.id === movingTarget.id;
if (!ancestorBlocked && !isBlockedFolder) {
destinations.push({
id: node.id,
label: node.label,
depth,
meta: "Folder",
});
}
destinations.push(
...collectMoveDestinations(node.children ?? [], movingTarget, depth + 1, ancestorBlocked || isBlockedFolder),
);
}
return destinations;
};
export const findTreeNodeById = (nodes: readonly WorkspaceTreeNode[], nodeId: string): WorkspaceTreeNode | null => {
for (const node of nodes) {
if (node.id === nodeId) {
return node;
}
if (node.kind !== "folder") {
continue;
}
const nestedMatch = findTreeNodeById(node.children ?? [], nodeId);
if (nestedMatch) {
return nestedMatch;
}
}
return null;
};
export const isDangerDialogState = (state: MobileWorkspaceDialogState): boolean => state.kind === "confirm" && state.tone === "danger";
@@ -0,0 +1,273 @@
// Path: Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.hook.ts
import { createSignal } from "solid-js";
import { createLongPressGesture } from "../../../helper/createLongPressGesture";
import { useAppShellData } from "../../app-shell/data/app-shell.context";
import {
createWorkspaceSurfaceTarget,
getWorkspaceItemTypeDefinition,
type WorkspaceContextMenuAction,
type WorkspaceContextMenuTarget,
type WorkspaceItemTypeId,
} from "../../app-shell/data/shell.data";
import { useWorkspaceTreeData } from "../shared/useWorkspaceTreeData";
import {
collectMoveDestinations,
findTreeNodeById,
type MobileMoveDestination,
type MobileMoveTargetState,
type MobileWorkspaceDialogState,
} from "./MobileWorkspaceBrowser.data";
export const useMobileWorkspaceBrowser = () => {
const appShellData = useAppShellData();
const [actionSheetTarget, setActionSheetTarget] = createSignal<WorkspaceContextMenuTarget | null>(null);
const [dialogState, setDialogState] = createSignal<MobileWorkspaceDialogState | null>(null);
const [dialogValue, setDialogValue] = createSignal("");
const [moveTarget, setMoveTarget] = createSignal<MobileMoveTargetState | null>(null);
const { workspaceTreeNodes, createFolder, renameFolder, deleteFolder, moveFolder, createItem, deleteItem, moveItem } = useWorkspaceTreeData({
activeProjectId: () => appShellData.activeProject().id,
fallbackWorkspaceTree: () => appShellData.workspaceTree(),
});
const workspaceTarget = () => createWorkspaceSurfaceTarget(appShellData.activeProject());
const moveDestinations = (): MobileMoveDestination[] => {
const target = moveTarget();
if (!target) {
return [];
}
return [{ id: null, label: "Items root", depth: 0, meta: "Root" }, ...collectMoveDestinations(workspaceTreeNodes(), target)];
};
const resolveCreateItemType = (actionId: string): WorkspaceItemTypeId | null => {
switch (actionId) {
case "create-doc":
return "core.doc";
case "create-board":
return "core.board.kanban";
case "create-list-board":
return "core.board.list";
default:
return null;
}
};
const createPersistedItem = (itemType: WorkspaceItemTypeId, parentId: string | null): void => {
const definition = getWorkspaceItemTypeDefinition(itemType);
void createItem(definition.defaultCreateLabel, itemType, parentId);
};
const openActionSheet = (target: WorkspaceContextMenuTarget): void => {
setActionSheetTarget(target);
};
const closeActionSheet = (): void => {
setActionSheetTarget(null);
};
const closeMoveSheet = (): void => {
setMoveTarget(null);
};
const openWorkspaceActionSheet = (): void => {
openActionSheet(workspaceTarget());
};
const closeDialog = (): void => {
setDialogState(null);
setDialogValue("");
};
const openMoveSheet = (target: MobileMoveTargetState): void => {
setMoveTarget(target);
};
const openTextDialog = (config: Omit<Extract<MobileWorkspaceDialogState, { kind: "text" }>, "kind">): void => {
setDialogValue(config.initialValue);
setDialogState({ kind: "text", ...config });
};
const openConfirmDialog = (config: Omit<Extract<MobileWorkspaceDialogState, { kind: "confirm" }>, "kind">): void => {
setDialogValue("");
setDialogState({ kind: "confirm", ...config });
};
const submitDialog = (): void => {
const state = dialogState();
if (!state) {
return;
}
if (state.kind === "text") {
const value = dialogValue().trim();
if (!value) {
return;
}
closeDialog();
state.onConfirm(value);
return;
}
closeDialog();
state.onConfirm();
};
const handleMoveDestinationSelect = (destinationId: string | null): void => {
const target = moveTarget();
if (!target) {
return;
}
const destinationNode = destinationId ? findTreeNodeById(workspaceTreeNodes(), destinationId) : null;
const targetIndex = destinationNode?.kind === "folder"
? destinationNode.children?.length ?? 0
: destinationId
? 0
: workspaceTreeNodes().length;
closeMoveSheet();
if (target.kind === "folder") {
void moveFolder(target.id, destinationId, targetIndex);
return;
}
void moveItem(target.id, destinationId, targetIndex);
};
const handleActionSelect = (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget): void => {
const createItemType = resolveCreateItemType(action.id);
if (createItemType) {
switch (target.kind) {
case "workspace":
case "home":
createPersistedItem(createItemType, null);
return;
case "folder":
createPersistedItem(createItemType, target.id);
return;
case "settings":
case "item":
return;
}
}
switch (action.id) {
case "new-folder": {
if (target.kind === "settings" || target.kind === "item") {
return;
}
openTextDialog({
title: "New folder",
message: target.kind === "folder" ? `Create a folder inside "${target.label}".` : "Create a folder at the root of Items.",
confirmLabel: "Create",
initialValue: "Untitled folder",
onConfirm: (name) => {
void createFolder(name, target.kind === "folder" ? target.id : null);
},
});
return;
}
case "rename-folder": {
if (target.kind !== "folder") {
return;
}
openTextDialog({
title: "Rename folder",
message: `Update the name for "${target.label}".`,
confirmLabel: "Save",
initialValue: target.label,
onConfirm: (name) => {
if (name === target.label) {
return;
}
void renameFolder(target.id, name);
},
});
return;
}
case "move-folder": {
if (target.kind !== "folder") {
return;
}
openMoveSheet({ kind: "folder", id: target.id, label: target.label });
return;
}
case "delete-folder": {
if (target.kind !== "folder") {
return;
}
openConfirmDialog({
title: "Delete folder?",
message: `"${target.label}" and everything inside it will be removed.`,
confirmLabel: "Delete",
tone: "danger",
onConfirm: () => {
void deleteFolder(target.id);
},
});
return;
}
case "move-doc":
case "move-board":
case "move-list-board": {
if (target.kind !== "item") {
return;
}
openMoveSheet({ kind: "item", id: target.id, label: target.label });
return;
}
case "delete-doc":
case "delete-board":
case "delete-list-board": {
if (target.kind !== "item") {
return;
}
openConfirmDialog({
title: "Delete item?",
message: `"${target.label}" will be removed from the project tree.`,
confirmLabel: "Delete",
tone: "danger",
onConfirm: () => {
void deleteItem(target.id);
},
});
return;
}
default:
return;
}
};
const workspaceLongPress = createLongPressGesture({
onLongPress: openWorkspaceActionSheet,
});
return {
appShellData,
actionSheetTarget,
openActionSheet,
closeActionSheet,
dialogState,
dialogValue,
setDialogValue,
closeDialog,
submitDialog,
moveTarget,
moveDestinations,
closeMoveSheet,
handleMoveDestinationSelect,
workspaceTreeNodes,
handleActionSelect,
workspaceLongPress,
openWorkspaceActionSheet,
};
};
@@ -0,0 +1,414 @@
.browserLayer {
display: none;
}
@include respond-down(mobile) {
.browserLayer {
position: fixed;
inset: 0;
display: grid;
z-index: calc(var(--z-popover, 20) + 2);
background: color-mix(in srgb, var(--color-canvas) 96%, black 4%);
}
.sheet {
height: 100dvh;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
background:
linear-gradient(180deg, color-mix(in srgb, var(--color-surface) 84%, transparent) 0%, transparent 8rem),
color-mix(in srgb, var(--color-canvas) 97%, black 3%);
overflow: hidden;
}
.sheetHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: calc(var(--space-5) + env(safe-area-inset-top, 0px)) var(--space-4) var(--space-4);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 84%, transparent);
}
.headerActions {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.brandBlock {
min-width: 0;
display: grid;
gap: var(--space-1);
}
.sectionLabel {
@include text-caption;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.brandEyebrow {
@include text-caption;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.brandTitle {
@include text-title;
color: var(--color-text);
font-weight: var(--font-weight-semibold);
}
.brandContext {
@include text-caption;
color: var(--color-text-subtle);
}
.closeButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--control-size-md);
height: var(--control-size-md);
padding: 0;
border: 1px solid color-mix(in srgb, var(--color-border) 92%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--color-surface) 92%, transparent);
color: var(--color-text-subtle);
}
.createButton {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-height: var(--control-size-md);
padding: 0 var(--space-3);
border: 1px solid color-mix(in srgb, var(--color-border-strong, var(--color-border)) 82%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--color-surface) 94%, var(--color-canvas) 6%);
color: var(--color-text);
box-shadow: var(--shadow-soft);
font: inherit;
font-weight: var(--font-weight-semibold);
}
.sheetBody {
min-height: 0;
display: flex;
flex-direction: column;
align-items: stretch;
gap: var(--space-5);
padding: var(--space-5) var(--space-5) calc(var(--space-12) + var(--space-10) + env(safe-area-inset-bottom, 0px));
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.sectionBlock {
display: grid;
gap: var(--space-2);
flex: 0 0 auto;
}
.treeList,
.treeListItem {
list-style: none;
margin: 0;
padding: 0;
}
.treeList {
display: grid;
gap: 0;
border-top: 1px solid color-mix(in srgb, var(--color-border) 92%, transparent);
}
.treeListNested {
display: grid;
gap: 0;
}
.treeRow {
min-width: 0;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
min-height: calc(var(--control-size-lg) + var(--space-3));
padding: var(--space-4) var(--space-2) var(--space-4) calc(var(--space-2) + (var(--tree-depth, 0) * var(--space-4)));
border: 0;
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 92%, transparent);
border-radius: 0;
background: transparent;
color: var(--color-text);
text-align: left;
}
.treeRowActive {
color: var(--color-text);
background: color-mix(in srgb, var(--color-surface) 72%, transparent);
}
.treeRowBranch {
font-weight: var(--font-weight-semibold);
}
.treeRowLead,
.treeRowTrail {
min-width: 0;
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.treeRowTrail {
flex: 0 0 auto;
color: var(--color-text-muted);
}
.treeLabel {
@include text-label;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.treeMeta {
@include text-caption;
min-width: 1rem;
text-align: right;
}
.treeChevron {
color: var(--color-text-subtle);
}
.treeListNested > .treeListItem > .treeRow {
min-height: calc(var(--control-size-lg) + var(--space-1));
}
.dialogLayer {
position: fixed;
inset: 0;
display: grid;
place-items: center;
z-index: calc(var(--z-modal, 30) + 2);
padding: var(--space-5);
}
.dialogBackdrop {
position: absolute;
inset: 0;
border: 0;
background: color-mix(in srgb, black 56%, transparent);
}
.dialogCard {
position: relative;
width: min(100%, 28rem);
display: grid;
gap: var(--space-4);
padding: var(--space-4);
border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent);
border-radius: var(--radius-xl);
background: color-mix(in srgb, var(--color-surface) 94%, var(--color-canvas) 6%);
box-shadow: var(--shadow-strong);
}
.dialogCopy {
display: grid;
gap: var(--space-2);
}
.dialogTitle {
@include text-title;
color: var(--color-text);
font-weight: var(--font-weight-semibold);
}
.dialogMessage {
@include text-body;
margin: 0;
color: var(--color-text-subtle);
}
.dialogInput {
width: 100%;
min-height: var(--control-size-lg);
padding: 0 var(--space-3);
border: 1px solid color-mix(in srgb, var(--color-border-strong, var(--color-border)) 82%, transparent);
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--color-canvas) 92%, var(--color-surface) 8%);
color: var(--color-text);
font: inherit;
}
.moveSheetLayer {
position: fixed;
inset: 0;
display: block;
z-index: calc(var(--z-modal, 30) + 2);
}
.moveSheet {
position: absolute;
right: 0;
bottom: 0;
left: 0;
max-height: calc(100dvh - (var(--space-12) * 2));
display: grid;
gap: var(--space-3);
padding: var(--space-3) var(--space-3) calc(var(--space-4) + env(safe-area-inset-bottom, 0px));
border-top: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent);
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
background: var(--color-surface);
box-shadow: var(--shadow-strong);
overflow: auto;
}
.moveSheetHandle {
width: var(--space-10);
height: var(--space-1);
margin: 0 auto;
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--color-text-muted) 24%, transparent);
}
.moveSheetHeader {
display: grid;
gap: var(--space-2);
padding-bottom: var(--space-3);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent);
}
.moveSheetHeaderCopy {
display: grid;
gap: calc(var(--space-1) / 2);
}
.moveSheetEyebrow {
@include text-caption;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.moveSheetTitle {
@include text-title;
color: var(--color-text);
font-weight: var(--font-weight-semibold);
}
.moveSheetMessage {
@include text-body;
margin: 0;
color: var(--color-text-subtle);
}
.moveSection {
display: grid;
gap: var(--space-2);
}
.moveSectionLabel {
@include text-caption;
padding-inline: var(--space-1);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.moveDestinationList {
display: grid;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent);
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--color-surface) 88%, var(--color-surface-elevated, var(--color-surface)) 12%);
max-height: min(20rem, 45vh);
overflow-y: auto;
}
.moveDestinationButton {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
min-height: calc(var(--control-size-lg) + var(--space-2));
padding: 0 var(--space-3);
padding-left: calc(var(--space-3) + (var(--move-depth, 0) * var(--space-4)));
border: 0;
background: transparent;
color: var(--color-text);
text-align: left;
}
.moveDestinationButton:active {
background: color-mix(in srgb, var(--color-text) 6%, transparent);
}
.moveDestinationButton + .moveDestinationButton {
border-top: 1px solid color-mix(in srgb, var(--color-border) 92%, transparent);
}
.moveDestinationLabel {
@include text-label;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.moveDestinationMeta {
@include text-caption;
color: var(--color-text-muted);
flex: 0 0 auto;
}
.moveSheetFooter {
display: flex;
justify-content: flex-end;
padding-top: var(--space-2);
border-top: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent);
}
.dialogActions {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
}
.dialogSecondaryButton,
.dialogPrimaryButton {
min-height: var(--control-size-md);
padding: 0 var(--space-3);
border-radius: 999px;
font: inherit;
font-weight: var(--font-weight-semibold);
}
.dialogSecondaryButton {
border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent);
background: color-mix(in srgb, var(--color-surface) 92%, transparent);
color: var(--color-text-subtle);
}
.dialogPrimaryButton {
border: 1px solid color-mix(in srgb, var(--color-border-strong, var(--color-border)) 82%, transparent);
background: color-mix(in srgb, var(--color-text) 10%, var(--color-surface) 90%);
color: var(--color-text);
}
.dialogDangerButton {
border-color: color-mix(in srgb, var(--color-danger-border, var(--color-border-strong, var(--color-border))) 82%, transparent);
background: color-mix(in srgb, var(--color-danger-text, #b42318) 12%, var(--color-surface) 88%);
color: var(--color-danger-text, #b42318);
}
}
@@ -0,0 +1,140 @@
// Path: Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.parts.tsx
import { For, Show, type JSX } from "solid-js";
import { ChevronRight } from "../../../lib/icons";
import { createLongPressGesture } from "../../../helper/createLongPressGesture";
import {
createWorkspaceStaticTarget,
createWorkspaceTreeTarget,
getWorkspaceNodeIcon,
type SidebarItem,
type WorkspaceContextMenuTarget,
type WorkspaceStaticItem,
type WorkspaceTreeNode,
} from "../../app-shell/data/shell.data";
import styles from "./MobileWorkspaceBrowser.module.scss";
export const TreeRow = (props: { node: WorkspaceTreeNode; depth?: number }): JSX.Element => {
const depth = props.depth ?? 0;
const Icon = getWorkspaceNodeIcon(props.node);
const hasChildren = (props.node.children?.length ?? 0) > 0;
return (
<button
classList={{
[styles.treeRow]: true,
[styles.treeRowActive]: props.node.active ?? false,
[styles.treeRowBranch]: hasChildren,
}}
type="button"
style={{ "--tree-depth": `${depth}` }}
data-slot="mobile-workspace-tree-row"
data-kind={props.node.kind}
data-item-type={props.node.kind === "item" ? props.node.itemType : undefined}
data-active={props.node.active ? "true" : "false"}
>
<span class={styles.treeRowLead}>
<Icon size={16} strokeWidth={2} />
<span class={styles.treeLabel}>{props.node.label}</span>
</span>
<span class={styles.treeRowTrail}>
<Show when={props.node.meta}>
<span class={styles.treeMeta}>{props.node.meta}</span>
</Show>
<Show when={hasChildren}>
<ChevronRight size={14} strokeWidth={2} class={styles.treeChevron} />
</Show>
</span>
</button>
);
};
export const StaticRow = (props: { item: SidebarItem }): JSX.Element => {
const Icon = props.item.icon;
return (
<button classList={{ [styles.treeRow]: true, [styles.treeRowActive]: props.item.active ?? false }} type="button" style={{ "--tree-depth": "0" }} data-slot="mobile-workspace-static-row" data-active={props.item.active ? "true" : "false"}>
<span class={styles.treeRowLead}>
<Icon size={16} strokeWidth={2} />
<span class={styles.treeLabel}>{props.item.label}</span>
</span>
<span class={styles.treeRowTrail}>
<Show when={props.item.meta}>
<span class={styles.treeMeta}>{props.item.meta}</span>
</Show>
<ChevronRight size={14} strokeWidth={2} class={styles.treeChevron} />
</span>
</button>
);
};
export const WorkspaceStaticRow = (props: {
item: WorkspaceStaticItem;
onOpenActionSheet: (target: WorkspaceContextMenuTarget) => void;
}): JSX.Element => {
const target = createWorkspaceStaticTarget(props.item);
const longPress = createLongPressGesture({
onLongPress: () => {
props.onOpenActionSheet(target);
},
});
return (
<li
class={styles.treeListItem}
data-slot="mobile-workspace-static-item"
data-target-kind={target.kind}
onContextMenu={(event): void => {
event.preventDefault();
props.onOpenActionSheet(target);
}}
{...longPress}
>
<StaticRow item={props.item} />
</li>
);
};
export const WorkspaceTreeBranch = (props: {
nodes: readonly WorkspaceTreeNode[];
depth?: number;
onOpenActionSheet: (target: WorkspaceContextMenuTarget) => void;
}): JSX.Element => {
const depth = props.depth ?? 0;
return (
<For each={props.nodes}>
{(node): JSX.Element => {
const target = createWorkspaceTreeTarget(node);
const longPress = createLongPressGesture({
onLongPress: () => {
props.onOpenActionSheet(target);
},
});
return (
<li
class={styles.treeListItem}
data-slot="mobile-workspace-tree-item"
data-kind={node.kind}
data-item-type={node.kind === "item" ? node.itemType : undefined}
onContextMenu={(event): void => {
event.preventDefault();
props.onOpenActionSheet(target);
}}
{...longPress}
>
<TreeRow node={node} depth={depth} />
<Show when={node.children?.length}>
<ul class={styles.treeListNested}>
<WorkspaceTreeBranch nodes={node.children ?? []} depth={depth + 1} onOpenActionSheet={props.onOpenActionSheet} />
</ul>
</Show>
</li>
);
}}
</For>
);
};
@@ -0,0 +1,192 @@
import { For, Show, type JSX } from "solid-js";
import { Portal } from "solid-js/web";
import { Plus, X } from "../../../lib/icons";
import {
workspaceStaticItems,
} from "../../app-shell/data/shell.data";
import { WorkspaceMobileActionSheet } from "../WorkspaceMobileActionSheet/WorkspaceMobileActionSheet";
import { useMobileWorkspaceBrowser } from "./MobileWorkspaceBrowser.hook";
import { isDangerDialogState, type MobileWorkspaceBrowserProps } from "./MobileWorkspaceBrowser.data";
import { WorkspaceStaticRow, WorkspaceTreeBranch } from "./MobileWorkspaceBrowser.parts";
import styles from "./MobileWorkspaceBrowser.module.scss";
export const MobileWorkspaceBrowser = (props: MobileWorkspaceBrowserProps): JSX.Element => {
const {
appShellData,
actionSheetTarget,
openActionSheet,
closeActionSheet,
dialogState,
dialogValue,
setDialogValue,
closeDialog,
submitDialog,
moveTarget,
moveDestinations,
closeMoveSheet,
handleMoveDestinationSelect,
workspaceTreeNodes,
handleActionSelect,
workspaceLongPress,
openWorkspaceActionSheet,
} = useMobileWorkspaceBrowser();
return (
<Show when={props.open}>
<div class={styles.browserLayer} data-ui="mobile-workspace-browser">
<section class={styles.sheet} aria-label="Mobile workspace browser" data-slot="mobile-workspace-sheet">
<header class={styles.sheetHeader} data-slot="mobile-workspace-header">
<div
class={styles.brandBlock}
data-slot="mobile-workspace-brand"
onContextMenu={(event): void => {
event.preventDefault();
openWorkspaceActionSheet();
}}
{...workspaceLongPress}
>
{/* Long-pressing the browser header exposes workspace-level actions on mobile. */}
<span class={styles.brandEyebrow}>Moku Work</span>
<strong class={styles.brandTitle}>{appShellData.activeProject().name}</strong>
<span class={styles.brandContext}>{appShellData.activeServer().name}</span>
</div>
<div class={styles.headerActions} data-slot="mobile-workspace-header-actions">
<button
class={styles.createButton}
type="button"
aria-label="Create"
data-slot="mobile-workspace-create"
onClick={openWorkspaceActionSheet}
>
<Plus size={16} strokeWidth={2.25} />
<span>Create</span>
</button>
<button class={styles.closeButton} type="button" aria-label="Close workspace browser" data-slot="mobile-workspace-close" onClick={props.onClose}>
<X size={18} strokeWidth={2} />
</button>
</div>
</header>
<div class={styles.sheetBody} data-slot="mobile-workspace-body">
<section class={styles.sectionBlock} data-slot="mobile-workspace-section" data-section-id="workspace">
<span class={styles.sectionLabel}>Workspace</span>
<ul class={styles.treeList} data-slot="mobile-workspace-list" data-section-id="workspace">
<For each={workspaceStaticItems}>
{(item): JSX.Element => <WorkspaceStaticRow item={item} onOpenActionSheet={openActionSheet} />}
</For>
</ul>
</section>
<section class={styles.sectionBlock} data-slot="mobile-workspace-section" data-section-id="items">
<span class={styles.sectionLabel}>Items</span>
<ul class={styles.treeList} data-slot="mobile-workspace-list" data-section-id="items">
<WorkspaceTreeBranch nodes={workspaceTreeNodes()} onOpenActionSheet={openActionSheet} />
</ul>
</section>
</div>
</section>
<WorkspaceMobileActionSheet target={actionSheetTarget()} onClose={closeActionSheet} onSelect={handleActionSelect} />
<Show when={moveTarget()}>
{(state): JSX.Element => (
<Portal>
<div class={styles.moveSheetLayer} data-ui="mobile-workspace-move-sheet">
<button class={styles.dialogBackdrop} type="button" aria-label="Close move sheet" onClick={closeMoveSheet} />
<section class={styles.moveSheet} aria-label={`Move ${state().label}`}>
<div class={styles.moveSheetHandle} aria-hidden="true" />
<div class={styles.moveSheetHeader}>
<div class={styles.moveSheetHeaderCopy}>
<span class={styles.moveSheetEyebrow}>Move {state().kind}</span>
<strong class={styles.moveSheetTitle}>{state().label}</strong>
<p class={styles.moveSheetMessage}>Choose a new location in the project tree.</p>
</div>
</div>
<div class={styles.moveSection}>
<span class={styles.moveSectionLabel}>Destination</span>
<div class={styles.moveDestinationList}>
<For each={moveDestinations()}>
{(destination): JSX.Element => (
<button
type="button"
class={styles.moveDestinationButton}
style={{ "--move-depth": `${destination.depth}` }}
onClick={(): void => handleMoveDestinationSelect(destination.id)}
>
<span class={styles.moveDestinationLabel}>{destination.label}</span>
<Show when={destination.meta}>
<span class={styles.moveDestinationMeta}>{destination.meta}</span>
</Show>
</button>
)}
</For>
</div>
</div>
<div class={styles.moveSheetFooter}>
<button class={styles.dialogSecondaryButton} type="button" onClick={closeMoveSheet}>
Cancel
</button>
</div>
</section>
</div>
</Portal>
)}
</Show>
<Show when={dialogState()}>
{(state): JSX.Element => (
<Portal>
<div class={styles.dialogLayer} data-ui="mobile-workspace-dialog">
<button class={styles.dialogBackdrop} type="button" aria-label="Close dialog" onClick={closeDialog} />
<section class={styles.dialogCard} aria-label={state().title}>
<div class={styles.dialogCopy}>
<strong class={styles.dialogTitle}>{state().title}</strong>
<p class={styles.dialogMessage}>{state().message}</p>
</div>
<Show when={state().kind === "text"}>
<input
class={styles.dialogInput}
type="text"
value={dialogValue()}
onInput={(event): void => {
setDialogValue(event.currentTarget.value);
}}
onKeyDown={(event): void => {
if (event.key === "Enter") {
event.preventDefault();
submitDialog();
}
}}
autofocus
/>
</Show>
<div class={styles.dialogActions}>
<button class={styles.dialogSecondaryButton} type="button" onClick={closeDialog}>
Cancel
</button>
<button
classList={{
[styles.dialogPrimaryButton]: true,
[styles.dialogDangerButton]: isDangerDialogState(state()),
}}
type="button"
onClick={submitDialog}
>
{state().confirmLabel}
</button>
</div>
</section>
</div>
</Portal>
)}
</Show>
</div>
</Show>
);
};
@@ -7,7 +7,7 @@ import {
type ProjectContextMenuAction, type ProjectContextMenuAction,
type ProjectMenuTarget, type ProjectMenuTarget,
type WorkspaceContextMenuShortcut, type WorkspaceContextMenuShortcut,
} from "../data/shell.data"; } from "../../app-shell/data/shell.data";
import styles from "../WorkspaceContextMenu/WorkspaceContextMenu.module.scss"; import styles from "../WorkspaceContextMenu/WorkspaceContextMenu.module.scss";
type ShortcutPlatform = "mac" | "windows"; type ShortcutPlatform = "mac" | "windows";
@@ -1,5 +1,5 @@
import { createEffect, createSignal, onCleanup } from "solid-js"; import { createEffect, createSignal, onCleanup } from "solid-js";
import type { ProjectMenuTarget } from "../data/shell.data"; import type { ProjectMenuTarget } from "../../app-shell/data/shell.data";
type ProjectContextMenuState = { type ProjectContextMenuState = {
target: ProjectMenuTarget; target: ProjectMenuTarget;
@@ -70,6 +70,15 @@ export const createProjectContextMenuController = () => {
setMenuState({ target, x: event.clientX, y: event.clientY }); setMenuState({ target, x: event.clientX, y: event.clientY });
}; };
const openMenuFromElement = (element: HTMLElement, target: ProjectMenuTarget): void => {
const rect = element.getBoundingClientRect();
setMenuState({
target,
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
});
};
createEffect(() => { createEffect(() => {
if (!menuState() || typeof window === "undefined") { if (!menuState() || typeof window === "undefined") {
return; return;
@@ -112,6 +121,7 @@ export const createProjectContextMenuController = () => {
return { return {
menuState, menuState,
openMenu, openMenu,
openMenuFromElement,
closeMenu, closeMenu,
setMenuRef: (element: HTMLDivElement): void => { setMenuRef: (element: HTMLDivElement): void => {
menuRef = element; menuRef = element;
@@ -0,0 +1,144 @@
// Path: Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.data.ts
import type { JSX } from "solid-js";
import type {
NavTreeAdapter,
NavTreeDropTarget,
} from "../shared/navTreeDnd";
import type { ProjectItem } from "../../app-shell/data/shell.data";
export type ProjectSelectorProps = {
compact?: boolean;
isOpen: boolean;
onToggle: () => void;
onClose: () => void;
};
export type ProjectFolderNode = {
kind: "folder";
id: string;
path: string;
label: string;
meta?: string;
children: ProjectTreeNode[];
};
export type ProjectLeafNode = {
kind: "project";
item: ProjectItem;
};
export type ProjectTreeNode = ProjectFolderNode | ProjectLeafNode;
export type PersistedProjectFolderRecord = {
id: string;
path: string;
label: string;
children: PersistedProjectFolderRecord[];
};
export type ProjectFoldersResponse = {
data?: {
folders?: PersistedProjectFolderRecord[];
renamedFolder?: PersistedProjectFolderRecord;
movedFolder?: PersistedProjectFolderRecord;
previousFolderId?: string;
previousFolderPath?: string;
};
error?: string;
message?: string;
};
export type PendingProjectFolderDraft = {
parentId: string | null;
depth: number;
};
export type PendingProjectFolderRename = {
folderId: string;
depth: number;
};
export type ProjectDragTarget = NavTreeDropTarget;
export type ProjectDragState = {
draggedNodeId: string;
dropTarget: ProjectDragTarget | null;
};
export type DragGhostPosition = {
x: number;
y: number;
};
export type ProjectFolderBranchProps = {
nodes: readonly ProjectTreeNode[];
depth: number;
parentId: string | null;
selectedProjectId: string;
isFolderCollapsed: (folderId: string) => boolean;
onToggleFolder: (folderId: string) => void;
onSelectProject: (projectId: string) => void;
onOpenFolderMenu: (event: MouseEvent, folder: ProjectFolderNode) => void;
onOpenProjectMenu: (event: MouseEvent, item: ProjectItem) => void;
onNodePointerDown: (event: PointerEvent, nodeId: string) => void;
onNodePointerMove: (event: PointerEvent, parentId: string | null, index: number, node: ProjectTreeNode) => void;
pendingFolderDraft: PendingProjectFolderDraft | null;
pendingFolderName: string;
onPendingFolderNameChange: (value: string) => void;
onSubmitPendingFolder: () => void;
onCancelPendingFolder: () => void;
pendingFolderRename: PendingProjectFolderRename | null;
pendingFolderRenameName: string;
onPendingFolderRenameChange: (value: string) => void;
onSubmitPendingFolderRename: () => void;
onCancelPendingFolderRename: () => void;
dragState: ProjectDragState | null;
isTreeClickSuppressed: () => boolean;
};
export const LONG_PRESS_MS = 320;
export const getProjectTreeNodeId = (node: ProjectTreeNode): string =>
node.kind === "folder" ? node.id : node.item.id;
export const buildPersistedFolderNodes = (folders: readonly PersistedProjectFolderRecord[] = []): ProjectTreeNode[] =>
folders.map((folder) => ({
kind: "folder",
id: folder.id,
path: folder.path,
label: folder.label,
children: buildPersistedFolderNodes(folder.children ?? []),
}));
export const buildProjectTree = (
items: readonly ProjectItem[],
folders: readonly PersistedProjectFolderRecord[] = [],
): ProjectTreeNode[] => [
...items.map((item) => ({
kind: "project" as const,
item,
})),
...buildPersistedFolderNodes(folders),
];
export const countProjectFolderSiblingsBeforeIndex = (
siblings: readonly ProjectTreeNode[],
index: number,
): number => siblings.slice(0, index).filter((node) => node.kind === "folder").length;
export const readPersistedFolders = (body: ProjectFoldersResponse): PersistedProjectFolderRecord[] =>
Array.isArray(body.data?.folders) ? body.data.folders : [];
export const projectTreeAdapter: NavTreeAdapter<ProjectTreeNode> = {
getNodeId: getProjectTreeNodeId,
isBranchNode: (node) => node.kind === "folder",
getChildren: (node) => (node.kind === "folder" ? node.children : []),
withChildren: (node, children) =>
node.kind === "folder"
? {
...node,
children: [...children],
}
: node,
};
@@ -0,0 +1,671 @@
// Path: Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.hook.ts
import { createEffect, createSignal, onCleanup, onMount } from "solid-js";
import { useAppShellData } from "../../app-shell/data/app-shell.context";
import {
createProjectFolderTarget,
createProjectSurfaceTarget,
createProjectTarget,
type ProjectItem,
type ProjectMenuTarget,
} from "../../app-shell/data/shell.data";
import {
collectBranchNodeIds,
findTreeNodeDepth,
findTreeNodeLocation,
getPointerRelativeY,
isUuidString,
moveTreeNode,
resolveTreeDropTarget,
} from "../shared/navTreeDnd";
import { createProjectContextMenuController } from "../ProjectContextMenu/createProjectContextMenuController";
import {
LONG_PRESS_MS,
buildProjectTree,
countProjectFolderSiblingsBeforeIndex,
projectTreeAdapter,
readPersistedFolders,
type DragGhostPosition,
type PendingProjectFolderDraft,
type PendingProjectFolderRename,
type PersistedProjectFolderRecord,
type ProjectDragState,
type ProjectFolderNode,
type ProjectSelectorProps,
type ProjectTreeNode,
} from "./ProjectSelector.data";
import {
createProjectFolderRequest,
deleteProjectFolderRequest,
fetchProjectFolders,
isValidProjectFolderProjectId,
moveProjectFolderRequest,
renameProjectFolderRequest,
} from "./projectFolders.api";
export const useProjectSelector = (props: ProjectSelectorProps) => {
const appShellData = useAppShellData();
const [selectedProject, setSelectedProject] = createSignal(appShellData.activeProject());
const [drawerTop, setDrawerTop] = createSignal<number>(0);
const [collapsedFolderIds, setCollapsedFolderIds] = createSignal<readonly string[]>([]);
const [persistedFolders, setPersistedFolders] = createSignal<readonly PersistedProjectFolderRecord[]>([]);
const [projectTreeNodes, setProjectTreeNodes] = createSignal<ProjectTreeNode[]>(buildProjectTree(appShellData.projectItems(), persistedFolders()));
const [pendingFolderDraft, setPendingFolderDraft] = createSignal<PendingProjectFolderDraft | null>(null);
const [pendingFolderName, setPendingFolderName] = createSignal("");
const [pendingFolderRename, setPendingFolderRename] = createSignal<PendingProjectFolderRename | null>(null);
const [pendingFolderRenameName, setPendingFolderRenameName] = createSignal("");
const [dragState, setDragState] = createSignal<ProjectDragState | null>(null);
const [dragGhostPosition, setDragGhostPosition] = createSignal<DragGhostPosition>({ x: 0, y: 0 });
const [suppressNextTreeClick, setSuppressNextTreeClick] = createSignal(false);
let rootRef: HTMLDivElement | undefined;
let triggerRef: HTMLButtonElement | undefined;
let contextMenuRef: HTMLDivElement | undefined;
let longPressTimer: number | undefined;
let suppressClickTimer: number | undefined;
let lastSelectedProjectId: string | null = null;
let latestPersistedFoldersRequest = 0;
const contextMenu = createProjectContextMenuController();
const setRootRef = (element: HTMLDivElement | undefined): void => {
rootRef = element;
};
const setTriggerRef = (element: HTMLButtonElement | undefined): void => {
triggerRef = element;
};
const setContextMenuElement = (element: HTMLDivElement | undefined): void => {
contextMenuRef = element;
if (element) {
contextMenu.setMenuRef(element);
}
};
const clearLongPressTimer = (): void => {
if (longPressTimer !== undefined) {
window.clearTimeout(longPressTimer);
longPressTimer = undefined;
}
};
const updateDragGhostPosition = (x: number, y: number): void => {
setDragGhostPosition({ x: x + 18, y: y + 18 });
};
const draggedNode = (): ProjectTreeNode | null => {
const currentDragState = dragState();
if (!currentDragState) {
return null;
}
return findTreeNodeLocation(projectTreeNodes(), currentDragState.draggedNodeId, projectTreeAdapter)?.node ?? null;
};
const draggedNodeMeta = (): string => {
const node = draggedNode();
if (!node) {
return "";
}
return node.kind === "folder" ? "Folder" : "Project";
};
const suppressTreeClickTemporarily = (): void => {
setSuppressNextTreeClick(true);
if (suppressClickTimer !== undefined) {
window.clearTimeout(suppressClickTimer);
}
suppressClickTimer = window.setTimeout(() => {
setSuppressNextTreeClick(false);
suppressClickTimer = undefined;
}, 80);
};
const isFolderCollapsed = (folderId: string): boolean => collapsedFolderIds().includes(folderId);
const toggleFolder = (folderId: string): void => {
setCollapsedFolderIds((current) => (current.includes(folderId) ? current.filter((id) => id !== folderId) : [...current, folderId]));
};
const syncProjectTree = (): void => {
const nextTree = buildProjectTree(appShellData.projectItems(), persistedFolders());
const availableFolderIds = new Set(collectBranchNodeIds(nextTree, projectTreeAdapter));
setProjectTreeNodes(nextTree);
setCollapsedFolderIds((current) => current.filter((folderId) => availableFolderIds.has(folderId)));
};
const resetProjectTreeInteractionState = (): void => {
setCollapsedFolderIds([]);
setPendingFolderDraft(null);
setPendingFolderName("");
setPendingFolderRename(null);
setPendingFolderRenameName("");
setDragState(null);
};
const folderIds = (): string[] => collectBranchNodeIds(projectTreeNodes(), projectTreeAdapter);
const expandAllFolders = (): void => {
setCollapsedFolderIds([]);
};
const collapseAllFolders = (): void => {
setCollapsedFolderIds(folderIds());
};
const totalFolderCount = (): number => folderIds().length;
const areAllFoldersCollapsed = (): boolean => {
const folderCount = totalFolderCount();
return folderCount > 0 && collapsedFolderIds().length >= folderCount;
};
const toggleAllFolders = (): void => {
if (areAllFoldersCollapsed()) {
expandAllFolders();
return;
}
collapseAllFolders();
};
const loadPersistedFolders = async (projectId: string): Promise<void> => {
const requestId = latestPersistedFoldersRequest + 1;
latestPersistedFoldersRequest = requestId;
if (!isValidProjectFolderProjectId(projectId) || !isUuidString(projectId)) {
setPersistedFolders([]);
return;
}
try {
const body = await fetchProjectFolders(projectId);
if (requestId !== latestPersistedFoldersRequest) {
return;
}
setPersistedFolders(readPersistedFolders(body));
} catch (error) {
if (requestId !== latestPersistedFoldersRequest) {
return;
}
console.error(error);
setPersistedFolders([]);
}
};
createEffect(() => {
setSelectedProject(appShellData.activeProject());
});
createEffect(() => {
syncProjectTree();
});
createEffect(() => {
const projectId = selectedProject().id;
if (lastSelectedProjectId === null) {
lastSelectedProjectId = projectId;
return;
}
if (projectId === lastSelectedProjectId) {
return;
}
lastSelectedProjectId = projectId;
resetProjectTreeInteractionState();
});
createEffect(() => {
void loadPersistedFolders(selectedProject().id);
});
onMount(() => {
if (triggerRef) {
const updateDrawerTop = (): void => {
if (!triggerRef) {
return;
}
setDrawerTop(triggerRef.offsetTop + triggerRef.offsetHeight + 8);
};
updateDrawerTop();
const observer = new ResizeObserver(() => {
updateDrawerTop();
});
observer.observe(triggerRef);
window.addEventListener("resize", updateDrawerTop);
onCleanup(() => {
observer.disconnect();
window.removeEventListener("resize", updateDrawerTop);
});
}
const handlePointerDown = (event: PointerEvent): void => {
if (!props.isOpen || !rootRef) {
return;
}
const target = event.target;
if (target instanceof Node && rootRef.contains(target)) {
return;
}
if (target instanceof Node && contextMenuRef?.contains(target)) {
return;
}
props.onClose();
};
const handlePointerUp = (): void => {
clearLongPressTimer();
const nextDragState = dragState();
if (!nextDragState?.dropTarget) {
if (nextDragState) {
suppressTreeClickTemporarily();
}
setDragState(null);
return;
}
suppressTreeClickTemporarily();
const currentNodes = projectTreeNodes();
const draggedLocation = findTreeNodeLocation(currentNodes, nextDragState.draggedNodeId, projectTreeAdapter);
const canPersistMove = isUuidString(selectedProject().id);
const draggedFolderPath = draggedLocation?.node.kind === "folder" ? draggedLocation.node.path : null;
const previewNodes = moveTreeNode(currentNodes, nextDragState.draggedNodeId, nextDragState.dropTarget, projectTreeAdapter);
const previewLocation = findTreeNodeLocation(previewNodes, nextDragState.draggedNodeId, projectTreeAdapter);
const persistedParentLocation = previewLocation?.parentId ? findTreeNodeLocation(previewNodes, previewLocation.parentId, projectTreeAdapter) : null;
const persistedParentFolderPath = persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.path : null;
const previewSiblings = previewLocation?.parentId ? persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.children : [] : previewNodes;
const targetIndex = previewLocation ? countProjectFolderSiblingsBeforeIndex(previewSiblings, previewLocation.index) : 0;
if (canPersistMove && draggedLocation?.node.kind === "folder" && draggedFolderPath && (!previewLocation?.parentId || persistedParentLocation?.node.kind === "folder")) {
void movePersistedFolder(
draggedFolderPath,
persistedParentFolderPath,
draggedLocation.node.id,
persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.id : null,
targetIndex,
);
} else {
setProjectTreeNodes((current) => moveTreeNode(current, nextDragState.draggedNodeId, nextDragState.dropTarget!, projectTreeAdapter));
}
setDragState(null);
};
const handleEscape = (event: KeyboardEvent): void => {
if (event.key !== "Escape") {
return;
}
clearLongPressTimer();
if (dragState()) {
setDragState(null);
return;
}
if (!props.isOpen) {
return;
}
props.onClose();
triggerRef?.focus();
};
const handlePointerMove = (event: PointerEvent): void => {
if (!dragState()) {
return;
}
updateDragGhostPosition(event.clientX, event.clientY);
};
document.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("keydown", handleEscape);
onCleanup(() => {
clearLongPressTimer();
if (suppressClickTimer !== undefined) {
window.clearTimeout(suppressClickTimer);
}
document.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("keydown", handleEscape);
});
});
const toggleOpen = (): void => {
if (!props.isOpen) {
props.onToggle();
return;
}
props.onClose();
};
const selectProject = (projectId: string): void => {
const location = findTreeNodeLocation(projectTreeNodes(), projectId, projectTreeAdapter);
if (!location || location.node.kind !== "project") {
return;
}
setSelectedProject({ id: location.node.item.id, name: location.node.item.name });
props.onClose();
};
const beginFolderDraft = (parentId: string | null, depth: number): void => {
if (parentId) {
setCollapsedFolderIds((current) => current.filter((id) => id !== parentId));
}
setPendingFolderRename(null);
setPendingFolderRenameName("");
setPendingFolderName("");
setPendingFolderDraft({ parentId, depth });
};
const beginFolderRename = (folderId: string, label: string, depth: number): void => {
setPendingFolderDraft(null);
setPendingFolderName("");
setPendingFolderRename({ folderId, depth });
setPendingFolderRenameName(label);
};
const resolveFolderPath = (folderId: string): string | null => {
const location = findTreeNodeLocation(projectTreeNodes(), folderId, projectTreeAdapter);
return location && location.node.kind === "folder" ? location.node.path : null;
};
const submitPendingFolder = async (): Promise<void> => {
const name = pendingFolderName().trim();
const draft = pendingFolderDraft();
const projectId = selectedProject().id;
if (!draft) {
return;
}
if (!name) {
setPendingFolderDraft(null);
setPendingFolderName("");
return;
}
if (!isUuidString(projectId)) {
cancelPendingFolder();
return;
}
const parentFolderPath = draft.parentId ? resolveFolderPath(draft.parentId) : null;
if (draft.parentId && !parentFolderPath) {
cancelPendingFolder();
return;
}
try {
const body = await createProjectFolderRequest(projectId, name, parentFolderPath);
setPersistedFolders(readPersistedFolders(body));
setPendingFolderDraft(null);
setPendingFolderName("");
} catch (error) {
console.error(error);
}
};
const deletePersistedFolder = async (folderId: string): Promise<void> => {
const projectId = selectedProject().id;
if (!folderId || !isUuidString(projectId)) {
return;
}
const folderPath = resolveFolderPath(folderId);
if (!folderPath) {
return;
}
try {
const body = await deleteProjectFolderRequest(projectId, folderPath);
setPersistedFolders(readPersistedFolders(body));
setCollapsedFolderIds((current) => current.filter((id) => id !== folderId));
} catch (error) {
console.error(error);
}
};
const movePersistedFolder = async (
folderPath: string,
parentFolderPath: string | null,
folderStableId: string,
parentStableId: string | null,
targetIndex: number,
): Promise<void> => {
const projectId = selectedProject().id;
if (!folderPath || !folderStableId || !isUuidString(projectId)) {
return;
}
try {
const body = await moveProjectFolderRequest(projectId, {
folderId: folderPath,
folderNodeId: folderStableId,
parentFolderId: parentFolderPath,
parentNodeId: parentStableId,
targetIndex,
});
setPersistedFolders(readPersistedFolders(body));
} catch (error) {
console.error(error);
}
};
const submitPendingFolderRename = async (): Promise<void> => {
const draft = pendingFolderRename();
const name = pendingFolderRenameName().trim();
const projectId = selectedProject().id;
if (!draft) {
return;
}
if (!name) {
setPendingFolderRename(null);
setPendingFolderRenameName("");
return;
}
if (!isUuidString(projectId)) {
cancelPendingFolderRename();
return;
}
const folderPath = resolveFolderPath(draft.folderId);
if (!folderPath) {
cancelPendingFolderRename();
return;
}
try {
const body = await renameProjectFolderRequest(projectId, folderPath, name);
setPersistedFolders(readPersistedFolders(body));
setPendingFolderRename(null);
setPendingFolderRenameName("");
} catch (error) {
console.error(error);
}
};
const cancelPendingFolder = (): void => {
setPendingFolderDraft(null);
setPendingFolderName("");
};
const cancelPendingFolderRename = (): void => {
setPendingFolderRename(null);
setPendingFolderRenameName("");
};
const handleContextActionSelect = (action: { id: string; label: string }, target: ProjectMenuTarget): void => {
switch (action.id) {
case "new-folder":
switch (target.kind) {
case "surface":
beginFolderDraft(null, 0);
return;
case "folder":
beginFolderDraft(target.id, (findTreeNodeDepth(projectTreeNodes(), target.id, projectTreeAdapter) ?? 0) + 1);
return;
case "project": {
const parentId = findTreeNodeLocation(projectTreeNodes(), target.id, projectTreeAdapter)?.parentId ?? null;
beginFolderDraft(parentId, parentId ? (findTreeNodeDepth(projectTreeNodes(), parentId, projectTreeAdapter) ?? 0) + 1 : 0);
return;
}
}
return;
case "delete-folder":
if (target.kind === "folder") {
void deletePersistedFolder(target.id);
}
return;
case "rename-folder":
if (target.kind === "folder") {
beginFolderRename(target.id, target.label, findTreeNodeDepth(projectTreeNodes(), target.id, projectTreeAdapter) ?? 0);
}
return;
default:
return;
}
};
const handleSurfaceContextMenu = (event: MouseEvent): void => {
event.stopPropagation();
contextMenu.openMenu(event, createProjectSurfaceTarget("Projects"));
};
const openRootCreateMenu = (element: HTMLElement): void => {
contextMenu.openMenuFromElement(element, createProjectSurfaceTarget("Projects"));
};
const treeControlLabel = (): string => (areAllFoldersCollapsed() ? "Expand all folders" : "Collapse all folders");
const handleNodePointerDown = (event: PointerEvent, nodeId: string): void => {
if (event.button !== 0 || pendingFolderDraft()) {
return;
}
updateDragGhostPosition(event.clientX, event.clientY);
clearLongPressTimer();
longPressTimer = window.setTimeout(() => {
suppressTreeClickTemporarily();
setDragState({ draggedNodeId: nodeId, dropTarget: null });
}, LONG_PRESS_MS);
};
const handleNodePointerMove = (event: PointerEvent, parentId: string | null, index: number, node: ProjectTreeNode): void => {
const nextDragState = dragState();
if (!nextDragState || nextDragState.draggedNodeId === (node.kind === "folder" ? node.id : node.item.id)) {
return;
}
const relativeY = getPointerRelativeY(event);
if (relativeY === null) {
return;
}
setDragState({
...nextDragState,
dropTarget: resolveTreeDropTarget({
parentId,
index,
node,
relativeY,
adapter: projectTreeAdapter,
}),
});
};
const openFolderMenu = (event: MouseEvent, folder: ProjectFolderNode): void => {
event.stopPropagation();
contextMenu.openMenu(event, createProjectFolderTarget(folder.id, folder.label));
};
const openProjectMenu = (event: MouseEvent, item: ProjectItem): void => {
event.stopPropagation();
contextMenu.openMenu(event, createProjectTarget(item));
};
const contextMenuPosition = () => {
const state = contextMenu.menuState();
return state ? { x: state.x, y: state.y } : null;
};
return {
selectedProject,
drawerTop,
setRootRef,
setTriggerRef,
setContextMenuElement,
toggleOpen,
handleSurfaceContextMenu,
openRootCreateMenu,
treeControlLabel,
toggleAllFolders,
totalFolderCount,
areAllFoldersCollapsed,
projectTreeNodes,
isFolderCollapsed,
toggleFolder,
selectProject,
openFolderMenu,
openProjectMenu,
handleNodePointerDown,
handleNodePointerMove,
pendingFolderDraft,
pendingFolderName,
setPendingFolderName,
submitPendingFolder,
cancelPendingFolder,
pendingFolderRename,
pendingFolderRenameName,
setPendingFolderRenameName,
submitPendingFolderRename,
cancelPendingFolderRename,
dragState,
suppressNextTreeClick,
contextMenu,
contextMenuPosition,
handleContextActionSelect,
draggedNode,
dragGhostPosition,
draggedNodeMeta,
};
};
@@ -315,6 +315,26 @@
@include treeNav.item-meta; @include treeNav.item-meta;
} }
.dragGhostLayer {
@include treeNav.drag-ghost-layer;
}
.dragGhost {
@include treeNav.drag-ghost;
}
.dragGhostCopy {
@include treeNav.drag-ghost-copy;
}
.dragGhostTitle {
@include treeNav.drag-ghost-title;
}
.dragGhostMeta {
@include treeNav.drag-ghost-meta;
}
@media (max-width: 720px) { @media (max-width: 720px) {
.rootCompact .scrim, .rootCompact .scrim,
.rootCompact .drawer { .rootCompact .drawer {
@@ -0,0 +1,213 @@
// Path: Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.parts.tsx
import { For, Show, type JSX } from "solid-js";
import { ChevronRight, Folder, LayoutGrid } from "../../../lib/icons";
import type { ProjectItem } from "../../app-shell/data/shell.data";
import {
getProjectTreeNodeId,
type PendingProjectFolderDraft,
type PendingProjectFolderRename,
type ProjectDragState,
type ProjectFolderBranchProps,
type ProjectFolderNode,
type ProjectTreeNode,
} from "./ProjectSelector.data";
import styles from "./ProjectSelector.module.scss";
export const ProjectFolderDraftRow = (props: {
depth: number;
value: string;
onInput: (value: string) => void;
onSubmit: () => void;
onCancel: () => void;
}): JSX.Element => {
let inputRef: HTMLInputElement | undefined;
queueMicrotask(() => inputRef?.focus());
return (
<li>
<div class={styles.treeInputRow} style={{ "--tree-depth": String(props.depth) }}>
<Folder class={styles.icon} size={18} strokeWidth={2} />
<input
ref={inputRef}
type="text"
class={styles.treeInput}
value={props.value}
placeholder="Folder name"
onInput={(event): void => props.onInput(event.currentTarget.value)}
onBlur={props.onSubmit}
onKeyDown={(event): void => {
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
return;
}
if (event.key === "Escape") {
event.preventDefault();
props.onCancel();
event.currentTarget.blur();
}
}}
/>
</div>
</li>
);
};
export const ProjectFolderBranch = (props: ProjectFolderBranchProps): JSX.Element => (
<ul class={styles.treeList} role="list">
<Show when={props.nodes.length === 0 && props.pendingFolderDraft?.parentId !== props.parentId}>
<li>
<div class={styles.treeEmptySlot} style={{ "--tree-depth": String(props.depth) }} />
</li>
</Show>
<For each={props.nodes}>
{(node, indexAccessor): JSX.Element => {
const nodeId = (): string => getProjectTreeNodeId(node);
const isDraggedNode = (): boolean => props.dragState?.draggedNodeId === nodeId();
const dropIntent = (): "before" | "after" | "inside" | null => {
if (props.dragState?.dropTarget?.targetNodeId !== nodeId()) {
return null;
}
return props.dragState.dropTarget.intent;
};
if (node.kind === "folder") {
const isCollapsed = (): boolean => props.isFolderCollapsed(node.id);
const isRenaming = (): boolean => props.pendingFolderRename?.folderId === node.id;
return (
<li>
<Show
when={isRenaming()}
fallback={
<button
type="button"
classList={{
[styles.treeItem]: true,
[styles.treeItemFolder]: true,
[styles.treeItemDragging]: isDraggedNode(),
[styles.treeItemDropBefore]: dropIntent() === "before",
[styles.treeItemDropAfter]: dropIntent() === "after",
[styles.treeItemDropInside]: dropIntent() === "inside",
}}
style={{ "--tree-depth": String(props.depth) }}
aria-expanded={!isCollapsed()}
onClick={() => {
if (props.dragState || props.isTreeClickSuppressed()) {
return;
}
props.onToggleFolder(node.id);
}}
onContextMenu={(event): void => props.onOpenFolderMenu(event, node)}
onPointerDown={(event): void => props.onNodePointerDown(event, node.id)}
onPointerMove={(event): void => props.onNodePointerMove(event, props.parentId, indexAccessor(), node)}
onPointerEnter={(event): void => props.onNodePointerMove(event, props.parentId, indexAccessor(), node)}
>
<ChevronRight
classList={{
[styles.folderChevron]: true,
[styles.folderChevronOpen]: !isCollapsed(),
}}
size={16}
strokeWidth={2}
/>
<Folder class={styles.icon} size={18} strokeWidth={2} />
<span class={styles.label}>{node.label}</span>
<Show when={node.meta}>
<span class={styles.itemMeta}>{node.meta}</span>
</Show>
</button>
}
>
<ProjectFolderDraftRow
depth={props.pendingFolderRename?.depth ?? props.depth}
value={props.pendingFolderRenameName}
onInput={props.onPendingFolderRenameChange}
onSubmit={props.onSubmitPendingFolderRename}
onCancel={props.onCancelPendingFolderRename}
/>
</Show>
<Show when={!isCollapsed() && ((node.children?.length ?? 0) > 0 || props.pendingFolderDraft?.parentId === node.id)}>
<ProjectFolderBranch
nodes={node.children}
depth={props.depth + 1}
parentId={node.id}
selectedProjectId={props.selectedProjectId}
isFolderCollapsed={props.isFolderCollapsed}
onToggleFolder={props.onToggleFolder}
onSelectProject={props.onSelectProject}
onOpenFolderMenu={props.onOpenFolderMenu}
onOpenProjectMenu={props.onOpenProjectMenu}
onNodePointerDown={props.onNodePointerDown}
onNodePointerMove={props.onNodePointerMove}
pendingFolderDraft={props.pendingFolderDraft}
pendingFolderName={props.pendingFolderName}
onPendingFolderNameChange={props.onPendingFolderNameChange}
onSubmitPendingFolder={props.onSubmitPendingFolder}
onCancelPendingFolder={props.onCancelPendingFolder}
pendingFolderRename={props.pendingFolderRename}
pendingFolderRenameName={props.pendingFolderRenameName}
onPendingFolderRenameChange={props.onPendingFolderRenameChange}
onSubmitPendingFolderRename={props.onSubmitPendingFolderRename}
onCancelPendingFolderRename={props.onCancelPendingFolderRename}
dragState={props.dragState}
isTreeClickSuppressed={props.isTreeClickSuppressed}
/>
</Show>
</li>
);
}
return (
<li>
<button
type="button"
classList={{
[styles.treeItem]: true,
[styles.treeItemActive]: props.selectedProjectId === node.item.id,
[styles.treeItemDragging]: isDraggedNode(),
[styles.treeItemDropBefore]: dropIntent() === "before",
[styles.treeItemDropAfter]: dropIntent() === "after",
}}
style={{ "--tree-depth": String(props.depth) }}
onClick={(): void => {
if (props.dragState || props.isTreeClickSuppressed()) {
return;
}
props.onSelectProject(node.item.id);
}}
onContextMenu={(event): void => props.onOpenProjectMenu(event, node.item)}
onPointerDown={(event): void => props.onNodePointerDown(event, node.item.id)}
onPointerMove={(event): void => props.onNodePointerMove(event, props.parentId, indexAccessor(), node)}
onPointerEnter={(event): void => props.onNodePointerMove(event, props.parentId, indexAccessor(), node)}
>
<LayoutGrid class={styles.icon} size={18} strokeWidth={2} />
<span class={styles.label}>{node.item.name}</span>
<Show when={node.item.meta}>
<span class={styles.itemMeta}>{node.item.meta}</span>
</Show>
</button>
</li>
);
}}
</For>
<Show when={props.pendingFolderDraft?.parentId === props.parentId}>
<ProjectFolderDraftRow
depth={props.pendingFolderDraft?.depth ?? props.depth}
value={props.pendingFolderName}
onInput={props.onPendingFolderNameChange}
onSubmit={props.onSubmitPendingFolder}
onCancel={props.onCancelPendingFolder}
/>
</Show>
</ul>
);
@@ -0,0 +1,222 @@
// Path: Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.tsx
import { Show, type JSX } from "solid-js";
import { Portal } from "solid-js/web";
import { ChevronDown, ChevronRight, Folder, LayoutGrid, ListCollapse, Plus, UnfoldVertical } from "../../../lib/icons";
import { ProjectContextMenu } from "../ProjectContextMenu/ProjectContextMenu";
import { useProjectSelector } from "./ProjectSelector.hook";
import { ProjectFolderBranch } from "./ProjectSelector.parts";
import { type ProjectSelectorProps } from "./ProjectSelector.data";
import styles from "./ProjectSelector.module.scss";
export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
const {
selectedProject,
drawerTop,
setRootRef,
setTriggerRef,
setContextMenuElement,
toggleOpen,
handleSurfaceContextMenu,
openRootCreateMenu,
treeControlLabel,
toggleAllFolders,
totalFolderCount,
areAllFoldersCollapsed,
projectTreeNodes,
isFolderCollapsed,
toggleFolder,
selectProject,
openFolderMenu,
openProjectMenu,
handleNodePointerDown,
handleNodePointerMove,
pendingFolderDraft,
pendingFolderName,
setPendingFolderName,
submitPendingFolder,
cancelPendingFolder,
pendingFolderRename,
pendingFolderRenameName,
setPendingFolderRenameName,
submitPendingFolderRename,
cancelPendingFolderRename,
dragState,
suppressNextTreeClick,
contextMenu,
contextMenuPosition,
handleContextActionSelect,
draggedNode,
dragGhostPosition,
draggedNodeMeta,
} = useProjectSelector(props);
return (
<div
ref={setRootRef}
classList={{
[styles.root]: true,
[styles.rootCompact]: !!props.compact,
[styles.rootDragMode]: !!dragState(),
}}
style={{
"--project-drawer-top": `${drawerTop()}px`,
}}
>
<button
type="button"
ref={setTriggerRef}
classList={{
[styles.trigger]: true,
[styles.triggerCompact]: !!props.compact,
[styles.triggerOpen]: props.isOpen,
}}
aria-label={`Open project menu for ${selectedProject().name}`}
aria-expanded={props.isOpen}
aria-haspopup="menu"
title={selectedProject().name}
onClick={toggleOpen}
>
<span class={styles.triggerLead} aria-hidden="true">
<Folder size={18} strokeWidth={2} />
</span>
{!props.compact ? (
<span class={styles.triggerCopy}>
<span class={styles.eyebrow}>Projects</span>
<span class={styles.value}>{selectedProject().name}</span>
</span>
) : null}
<ChevronDown
classList={{
[styles.triggerIcon]: true,
[styles.triggerIconOpen]: props.isOpen,
}}
size={16}
strokeWidth={2}
/>
</button>
<Show when={props.isOpen}>
<>
<button
type="button"
classList={{
[styles.scrim]: true,
[styles.scrimOpen]: props.isOpen,
}}
aria-hidden={!props.isOpen}
tabIndex={props.isOpen ? 0 : -1}
onClick={props.onClose}
/>
<div
classList={{
[styles.drawer]: true,
[styles.drawerOpen]: props.isOpen,
}}
aria-hidden={!props.isOpen}
onContextMenu={handleSurfaceContextMenu}
>
<div class={styles.drawerBody}>
<div class={styles.treeSectionHeader}>
<Show when={!props.compact}>
<div class={styles.treeSectionLabel}>Projects</div>
</Show>
<div class={styles.treeControls}>
<button
type="button"
class={styles.treeControlButton}
onClick={(event): void => {
event.stopPropagation();
openRootCreateMenu(event.currentTarget);
}}
aria-label="Create in Projects"
title="Create"
>
<Plus size={16} strokeWidth={2.25} />
</button>
<button
type="button"
class={styles.treeControlButton}
onClick={toggleAllFolders}
aria-label={treeControlLabel()}
title={treeControlLabel()}
disabled={totalFolderCount() === 0}
>
<Show
when={areAllFoldersCollapsed()}
fallback={<ListCollapse size={16} strokeWidth={2} />}
>
<UnfoldVertical size={16} strokeWidth={2} />
</Show>
</button>
</div>
</div>
<ProjectFolderBranch
nodes={projectTreeNodes()}
depth={0}
parentId={null}
selectedProjectId={selectedProject().id}
isFolderCollapsed={isFolderCollapsed}
onToggleFolder={toggleFolder}
onSelectProject={selectProject}
onOpenFolderMenu={openFolderMenu}
onOpenProjectMenu={openProjectMenu}
onNodePointerDown={handleNodePointerDown}
onNodePointerMove={handleNodePointerMove}
pendingFolderDraft={pendingFolderDraft()}
pendingFolderName={pendingFolderName()}
onPendingFolderNameChange={setPendingFolderName}
onSubmitPendingFolder={submitPendingFolder}
onCancelPendingFolder={cancelPendingFolder}
pendingFolderRename={pendingFolderRename()}
pendingFolderRenameName={pendingFolderRenameName()}
onPendingFolderRenameChange={setPendingFolderRenameName}
onSubmitPendingFolderRename={submitPendingFolderRename}
onCancelPendingFolderRename={cancelPendingFolderRename}
dragState={dragState()}
isTreeClickSuppressed={suppressNextTreeClick}
/>
</div>
</div>
</>
</Show>
<ProjectContextMenu
target={contextMenu.menuState()?.target ?? null}
position={contextMenuPosition()}
menuRef={setContextMenuElement}
onClose={contextMenu.closeMenu}
onSelect={handleContextActionSelect}
/>
<Show when={draggedNode()} keyed>
{(node): JSX.Element => {
const GhostIcon = node.kind === "folder" ? Folder : LayoutGrid;
return (
<Portal>
<div class={styles.dragGhostLayer} aria-hidden="true">
<div
class={styles.dragGhost}
style={{
"--drag-ghost-x": `${dragGhostPosition().x}px`,
"--drag-ghost-y": `${dragGhostPosition().y}px`,
}}
>
<GhostIcon class={styles.icon} size={18} strokeWidth={2} />
<div class={styles.dragGhostCopy}>
<div class={styles.dragGhostTitle}>{node.kind === "folder" ? node.label : node.item.name}</div>
<div class={styles.dragGhostMeta}>{draggedNodeMeta()}</div>
</div>
</div>
</div>
</Portal>
);
}}
</Show>
</div>
);
};
@@ -0,0 +1,109 @@
import { resolveAPIBase } from "../../../lib/api";
import type { ProjectFoldersResponse } from "./ProjectSelector.data";
const readProjectFoldersResponse = async (response: Response): Promise<ProjectFoldersResponse> =>
(await response.json()) as ProjectFoldersResponse;
export const isValidProjectFolderProjectId = (projectId: string): boolean =>
Boolean(projectId);
export const fetchProjectFolders = async (projectId: string): Promise<ProjectFoldersResponse> => {
const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, {
headers: { Accept: "application/json" },
});
const body = await readProjectFoldersResponse(response);
if (!response.ok) {
throw new Error(body.message || "Failed to load project folders.");
}
return body;
};
export const createProjectFolderRequest = async (
projectId: string,
name: string,
parentFolderId: string | null,
): Promise<ProjectFoldersResponse> => {
const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ name, parentFolderId }),
});
const body = await readProjectFoldersResponse(response);
if (!response.ok) {
throw new Error(body.message || "Failed to create project folder.");
}
return body;
};
export const deleteProjectFolderRequest = async (
projectId: string,
folderId: string,
): Promise<ProjectFoldersResponse> => {
const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders?folderId=${encodeURIComponent(folderId)}`, {
method: "DELETE",
headers: { Accept: "application/json" },
});
const body = await readProjectFoldersResponse(response);
if (!response.ok) {
throw new Error(body.message || "Failed to delete project folder.");
}
return body;
};
export const moveProjectFolderRequest = async (
projectId: string,
payload: {
folderId: string;
folderNodeId: string;
parentFolderId: string | null;
parentNodeId: string | null;
targetIndex: number;
},
): Promise<ProjectFoldersResponse> => {
const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders/move`, {
method: "PATCH",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const body = await readProjectFoldersResponse(response);
if (!response.ok) {
throw new Error(body.message || "Failed to move project folder.");
}
return body;
};
export const renameProjectFolderRequest = async (
projectId: string,
folderId: string,
name: string,
): Promise<ProjectFoldersResponse> => {
const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, {
method: "PATCH",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ folderId, name }),
});
const body = await readProjectFoldersResponse(response);
if (!response.ok) {
throw new Error(body.message || "Failed to rename project folder.");
}
return body;
};
@@ -7,7 +7,7 @@ import {
type WorkspaceContextMenuAction, type WorkspaceContextMenuAction,
type WorkspaceContextMenuShortcut, type WorkspaceContextMenuShortcut,
type WorkspaceContextMenuTarget, type WorkspaceContextMenuTarget,
} from "../data/shell.data"; } from "../../app-shell/data/shell.data";
import styles from "./WorkspaceContextMenu.module.scss"; import styles from "./WorkspaceContextMenu.module.scss";
type ShortcutPlatform = "mac" | "windows"; type ShortcutPlatform = "mac" | "windows";
@@ -1,5 +1,5 @@
import { createEffect, createSignal, onCleanup } from "solid-js"; import { createEffect, createSignal, onCleanup } from "solid-js";
import type { WorkspaceContextMenuTarget } from "../data/shell.data"; import type { WorkspaceContextMenuTarget } from "../../app-shell/data/shell.data";
type WorkspaceContextMenuState = { type WorkspaceContextMenuState = {
target: WorkspaceContextMenuTarget; target: WorkspaceContextMenuTarget;
@@ -7,7 +7,7 @@ import {
type WorkspaceContextMenuAction, type WorkspaceContextMenuAction,
type WorkspaceContextMenuSection, type WorkspaceContextMenuSection,
type WorkspaceContextMenuTarget, type WorkspaceContextMenuTarget,
} from "../data/shell.data"; } from "../../app-shell/data/shell.data";
import styles from "./WorkspaceMobileActionSheet.module.scss"; import styles from "./WorkspaceMobileActionSheet.module.scss";
type WorkspaceMobileActionSheetProps = { type WorkspaceMobileActionSheetProps = {
@@ -0,0 +1,57 @@
// Path: Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.data.ts
import type { NavTreeAdapter, NavTreeDropTarget } from "../shared/navTreeDnd";
import type { WorkspaceTreeNode } from "../../app-shell/data/shell.data";
export type WorkspaceSidebarProps = {
collapsed: boolean;
railCollapsed: boolean;
onToggleRailCollapse: () => void;
};
export type PendingWorkspaceFolderDraft = {
parentId: string | null;
depth: number;
};
export type WorkspaceDragTarget = NavTreeDropTarget;
export type WorkspaceDragState = {
draggedNodeId: string;
dropTarget: WorkspaceDragTarget | null;
};
export type DragGhostPosition = {
x: number;
y: number;
};
export type PendingWorkspaceFolderRename = {
folderId: string;
depth: number;
};
export const LONG_PRESS_MS = 320;
export const getWorkspaceTreeNodeId = (node: WorkspaceTreeNode): string => node.id;
export const countWorkspaceFolderSiblingsBeforeIndex = (
siblings: readonly WorkspaceTreeNode[],
index: number,
): number => siblings.slice(0, index).filter((node) => node.kind === "folder").length;
export const workspaceTreeAdapter: NavTreeAdapter<WorkspaceTreeNode> = {
getNodeId: getWorkspaceTreeNodeId,
isBranchNode: (node) => node.kind === "folder",
getChildren: (node) => (node.kind === "folder" ? (node.children ?? []) : []),
withChildren: (node, children) =>
node.kind === "folder"
? {
...node,
children: [...children],
}
: node,
};
export const isContextMenuKeyboardTrigger = (event: KeyboardEvent): boolean =>
event.key === "ContextMenu" || (event.shiftKey && event.key === "F10");
@@ -0,0 +1,534 @@
// Path: Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.hook.ts
import { createEffect, createSignal, onCleanup, onMount } from "solid-js";
import { useAppShellData } from "../../app-shell/data/app-shell.context";
import {
createWorkspaceSurfaceTarget,
getWorkspaceItemTypeDefinition,
type WorkspaceContextMenuAction,
type WorkspaceContextMenuTarget,
type WorkspaceItemTypeId,
type WorkspaceTreeNode,
} from "../../app-shell/data/shell.data";
import {
collectBranchNodeIds,
findTreeNodeDepth,
findTreeNodeLocation,
getPointerRelativeY,
isUuidString,
moveTreeNode,
resolveTreeDropTarget,
} from "../shared/navTreeDnd";
import { useWorkspaceTreeData } from "../shared/useWorkspaceTreeData";
import { createWorkspaceContextMenuController } from "../WorkspaceContextMenu/createWorkspaceContextMenuController";
import {
countWorkspaceFolderSiblingsBeforeIndex,
getWorkspaceTreeNodeId,
LONG_PRESS_MS,
workspaceTreeAdapter,
type DragGhostPosition,
type PendingWorkspaceFolderDraft,
type PendingWorkspaceFolderRename,
type WorkspaceDragTarget,
type WorkspaceDragState,
} from "./WorkspaceSidebar.data";
export const useWorkspaceSidebar = () => {
const appShellData = useAppShellData();
const activeProject = () => appShellData.activeProject();
const [isProjectDrawerOpen, setIsProjectDrawerOpen] = createSignal(false);
const [collapsedFolderIds, setCollapsedFolderIds] = createSignal<readonly string[]>([]);
const [pendingFolderDraft, setPendingFolderDraft] = createSignal<PendingWorkspaceFolderDraft | null>(null);
const [pendingFolderName, setPendingFolderName] = createSignal("");
const [pendingFolderRename, setPendingFolderRename] = createSignal<PendingWorkspaceFolderRename | null>(null);
const [pendingFolderRenameName, setPendingFolderRenameName] = createSignal("");
const [dragState, setDragState] = createSignal<WorkspaceDragState | null>(null);
const [dragGhostPosition, setDragGhostPosition] = createSignal<DragGhostPosition>({ x: 0, y: 0 });
const [suppressNextTreeClick, setSuppressNextTreeClick] = createSignal(false);
let lastSelectedProjectId: string | null = null;
const {
workspaceTreeNodes,
setWorkspaceTreeNodes,
resolveFolderPath,
resolveItemPath,
createFolder,
renameFolder,
deleteFolder,
moveFolder,
createItem,
deleteItem,
moveItem,
} = useWorkspaceTreeData({
activeProjectId: () => activeProject()?.id ?? "",
fallbackWorkspaceTree: () => appShellData.workspaceTree(),
});
const contextMenu = createWorkspaceContextMenuController();
let longPressTimer: number | undefined;
let suppressClickTimer: number | undefined;
const sidebarContextMenuTarget = () => createWorkspaceSurfaceTarget(appShellData.activeProject());
const isFolderCollapsed = (folderId: string): boolean => collapsedFolderIds().includes(folderId);
const folderIds = (): string[] => collectBranchNodeIds(workspaceTreeNodes(), workspaceTreeAdapter);
const totalFolderCount = (): number => folderIds().length;
const areAllFoldersCollapsed = (): boolean => {
const count = totalFolderCount();
return count > 0 && collapsedFolderIds().length >= count;
};
const workspaceFolderToggleLabel = (): string =>
areAllFoldersCollapsed() ? "Expand all folders" : "Collapse all folders";
const toggleFolder = (folderId: string): void => {
setCollapsedFolderIds((current) =>
current.includes(folderId) ? current.filter((id) => id !== folderId) : [...current, folderId],
);
};
const expandAllFolders = (): void => {
setCollapsedFolderIds([]);
};
const collapseAllFolders = (): void => {
setCollapsedFolderIds(folderIds());
};
const toggleAllFolders = (): void => {
if (areAllFoldersCollapsed()) {
expandAllFolders();
return;
}
collapseAllFolders();
};
const resetWorkspaceTreeInteractionState = (): void => {
setCollapsedFolderIds([]);
setPendingFolderDraft(null);
setPendingFolderName("");
setPendingFolderRename(null);
setPendingFolderRenameName("");
setDragState(null);
};
const syncCollapsedFolderIds = (): void => {
const availableFolderIds = new Set(collectBranchNodeIds(workspaceTreeNodes(), workspaceTreeAdapter));
setCollapsedFolderIds((current) => current.filter((id) => availableFolderIds.has(id)));
};
const updateDragGhostPosition = (x: number, y: number): void => {
setDragGhostPosition({ x: x + 18, y: y + 18 });
};
const draggedNode = (): WorkspaceTreeNode | null => {
const currentDragState = dragState();
if (!currentDragState) {
return null;
}
return findTreeNodeLocation(workspaceTreeNodes(), currentDragState.draggedNodeId, workspaceTreeAdapter)?.node ?? null;
};
const draggedNodeMeta = (): string => {
const node = draggedNode();
if (!node) {
return "";
}
if (node.kind === "folder") {
return "Folder";
}
return getWorkspaceItemTypeDefinition(node.itemType).label;
};
const clearLongPressTimer = (): void => {
if (longPressTimer !== undefined) {
window.clearTimeout(longPressTimer);
longPressTimer = undefined;
}
};
const suppressTreeClickTemporarily = (): void => {
setSuppressNextTreeClick(true);
if (suppressClickTimer !== undefined) {
window.clearTimeout(suppressClickTimer);
}
suppressClickTimer = window.setTimeout(() => {
setSuppressNextTreeClick(false);
suppressClickTimer = undefined;
}, 80);
};
createEffect(() => {
syncCollapsedFolderIds();
});
createEffect(() => {
const projectId = activeProject()?.id ?? null;
if (lastSelectedProjectId === null) {
lastSelectedProjectId = projectId;
return;
}
if (projectId === lastSelectedProjectId) {
return;
}
lastSelectedProjectId = projectId;
resetWorkspaceTreeInteractionState();
});
onMount(() => {
const handlePointerMove = (event: PointerEvent): void => {
if (!dragState()) {
return;
}
updateDragGhostPosition(event.clientX, event.clientY);
};
const handlePointerUp = (): void => {
clearLongPressTimer();
const nextDragState = dragState();
if (!nextDragState?.dropTarget) {
if (nextDragState) {
suppressTreeClickTemporarily();
}
setDragState(null);
return;
}
suppressTreeClickTemporarily();
const currentNodes = workspaceTreeNodes();
const draggedLocation = findTreeNodeLocation(currentNodes, nextDragState.draggedNodeId, workspaceTreeAdapter);
const canPersistMove = isUuidString(activeProject()?.id ?? "");
const previewNodes = moveTreeNode(currentNodes, nextDragState.draggedNodeId, nextDragState.dropTarget as WorkspaceDragTarget, workspaceTreeAdapter);
const previewLocation = findTreeNodeLocation(previewNodes, nextDragState.draggedNodeId, workspaceTreeAdapter);
const persistedParentLocation = previewLocation?.parentId
? findTreeNodeLocation(previewNodes, previewLocation.parentId, workspaceTreeAdapter)
: null;
const previewSiblings = previewLocation?.parentId
? persistedParentLocation?.node.kind === "folder"
? persistedParentLocation.node.children ?? []
: []
: previewNodes;
const targetIndex = previewLocation
? countWorkspaceFolderSiblingsBeforeIndex(previewSiblings, previewLocation.index)
: 0;
if (
canPersistMove &&
draggedLocation?.node.kind === "folder" &&
(!previewLocation?.parentId || persistedParentLocation?.node.kind === "folder")
) {
void moveFolder(
draggedLocation.node.id,
persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.id : null,
targetIndex,
);
} else if (
canPersistMove &&
draggedLocation?.node.kind === "item" &&
resolveItemPath(draggedLocation.node.id) &&
(!previewLocation?.parentId || persistedParentLocation?.node.kind === "folder")
) {
void moveItem(
draggedLocation.node.id,
persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.id : null,
previewLocation?.index ?? 0,
);
} else {
setWorkspaceTreeNodes((current) =>
moveTreeNode(current, nextDragState.draggedNodeId, nextDragState.dropTarget as WorkspaceDragTarget, workspaceTreeAdapter),
);
}
setDragState(null);
};
const handleEscape = (event: KeyboardEvent): void => {
if (event.key !== "Escape") {
return;
}
clearLongPressTimer();
if (dragState()) {
setDragState(null);
}
};
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("keydown", handleEscape);
onCleanup(() => {
clearLongPressTimer();
if (suppressClickTimer !== undefined) {
window.clearTimeout(suppressClickTimer);
}
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("keydown", handleEscape);
});
});
const beginFolderDraft = (parentId: string | null, depth: number): void => {
if (parentId) {
setCollapsedFolderIds((current) => current.filter((id) => id !== parentId));
}
setPendingFolderRename(null);
setPendingFolderRenameName("");
setPendingFolderName("");
setPendingFolderDraft({ parentId, depth });
};
const beginFolderRename = (folderId: string, label: string, depth: number): void => {
setPendingFolderDraft(null);
setPendingFolderName("");
setPendingFolderRename({ folderId, depth });
setPendingFolderRenameName(label);
};
const submitPendingFolder = async (): Promise<void> => {
const name = pendingFolderName().trim();
const draft = pendingFolderDraft();
const projectId = activeProject()?.id ?? "";
if (!draft) {
return;
}
if (!name) {
setPendingFolderDraft(null);
setPendingFolderName("");
return;
}
if (!projectId || !isUuidString(projectId)) {
cancelPendingFolder();
return;
}
const parentFolderPath = draft.parentId ? resolveFolderPath(draft.parentId) : null;
if (draft.parentId && !parentFolderPath) {
cancelPendingFolder();
return;
}
const created = await createFolder(name, draft.parentId);
if (created) {
setPendingFolderDraft(null);
setPendingFolderName("");
}
};
const deletePersistedFolder = async (folderId: string): Promise<void> => {
const deleted = await deleteFolder(folderId);
if (deleted) {
setCollapsedFolderIds((current) => current.filter((id) => id !== folderId));
}
};
const submitPendingFolderRename = async (): Promise<void> => {
const draft = pendingFolderRename();
const name = pendingFolderRenameName().trim();
const projectId = activeProject()?.id ?? "";
if (!draft) {
return;
}
if (!name) {
setPendingFolderRename(null);
setPendingFolderRenameName("");
return;
}
if (!projectId || !isUuidString(projectId)) {
cancelPendingFolderRename();
return;
}
const folderPath = resolveFolderPath(draft.folderId);
if (!folderPath) {
cancelPendingFolderRename();
return;
}
const renamed = await renameFolder(draft.folderId, name);
if (renamed) {
setPendingFolderRename(null);
setPendingFolderRenameName("");
}
};
const cancelPendingFolder = (): void => {
setPendingFolderDraft(null);
setPendingFolderName("");
};
const cancelPendingFolderRename = (): void => {
setPendingFolderRename(null);
setPendingFolderRenameName("");
};
const handleHeaderActionClick = (actionId: string): void => {
switch (actionId) {
case "toggle-workspace-folders":
toggleAllFolders();
return;
case "search-workspace":
default:
return;
}
};
const openWorkspaceCreateMenu = (element: HTMLElement): void => {
contextMenu.openMenuFromElement(element, sidebarContextMenuTarget());
};
const resolveCreateItemType = (actionId: string): WorkspaceItemTypeId | null => {
switch (actionId) {
case "create-doc":
return "core.doc";
case "create-board":
return "core.board.kanban";
case "create-list-board":
return "core.board.list";
default:
return null;
}
};
const createPersistedItem = async (itemType: WorkspaceItemTypeId, parentId: string | null): Promise<void> => {
const definition = getWorkspaceItemTypeDefinition(itemType);
await createItem(definition.defaultCreateLabel, itemType, parentId);
};
const handleNodePointerDown = (event: PointerEvent, nodeId: string): void => {
if (event.button !== 0 || pendingFolderDraft()) {
return;
}
updateDragGhostPosition(event.clientX, event.clientY);
clearLongPressTimer();
longPressTimer = window.setTimeout(() => {
suppressTreeClickTemporarily();
setDragState({ draggedNodeId: nodeId, dropTarget: null });
}, LONG_PRESS_MS);
};
const handleNodePointerMove = (
event: PointerEvent,
parentId: string | null,
index: number,
node: WorkspaceTreeNode,
): void => {
const nextDragState = dragState();
if (!nextDragState || nextDragState.draggedNodeId === getWorkspaceTreeNodeId(node)) {
return;
}
const relativeY = getPointerRelativeY(event);
if (relativeY === null) {
return;
}
setDragState({
...nextDragState,
dropTarget: resolveTreeDropTarget({
parentId,
index,
node,
relativeY,
adapter: workspaceTreeAdapter,
}),
});
};
const handleContextActionSelect = (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget): void => {
const createItemType = resolveCreateItemType(action.id);
if (createItemType) {
switch (target.kind) {
case "workspace":
case "home":
void createPersistedItem(createItemType, null);
return;
case "folder":
void createPersistedItem(createItemType, target.id);
return;
case "settings":
case "item":
return;
}
}
switch (action.id) {
case "new-folder":
switch (target.kind) {
case "workspace":
case "home":
beginFolderDraft(null, 0);
return;
case "folder":
beginFolderDraft(target.id, (findTreeNodeDepth(workspaceTreeNodes(), target.id, workspaceTreeAdapter) ?? 0) + 1);
return;
case "settings":
case "item":
return;
}
return;
case "delete-folder":
if (target.kind === "folder") {
void deletePersistedFolder(target.id);
}
return;
case "rename-folder":
if (target.kind === "folder") {
beginFolderRename(target.id, target.label, findTreeNodeDepth(workspaceTreeNodes(), target.id, workspaceTreeAdapter) ?? 0);
}
return;
case "delete-doc":
case "delete-board":
case "delete-list-board":
if (target.kind === "item") {
void deleteItem(target.id);
}
return;
default:
return;
}
};
return {
activeProject,
contextMenu,
sidebarContextMenuTarget,
isProjectDrawerOpen,
setIsProjectDrawerOpen,
workspaceTreeNodes,
isFolderCollapsed,
pendingFolderDraft,
pendingFolderName,
setPendingFolderName,
submitPendingFolder,
cancelPendingFolder,
pendingFolderRename,
pendingFolderRenameName,
setPendingFolderRenameName,
submitPendingFolderRename,
cancelPendingFolderRename,
handleNodePointerDown,
handleNodePointerMove,
dragState,
suppressNextTreeClick,
draggedNode,
dragGhostPosition,
draggedNodeMeta,
workspaceFolderToggleLabel,
areAllFoldersCollapsed,
totalFolderCount,
handleHeaderActionClick,
openWorkspaceCreateMenu,
handleContextActionSelect,
toggleFolder,
};
};
@@ -124,6 +124,53 @@
padding: 0 var(--space-3); padding: 0 var(--space-3);
} }
.treeSectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin-top: var(--space-3);
margin-bottom: var(--space-2);
padding-right: var(--space-1);
}
.treeSectionHeader .treeSectionLabel {
margin: 0;
}
.treeControls {
display: inline-flex;
align-items: center;
gap: var(--space-1);
justify-content: flex-end;
padding-right: var(--space-2);
}
.treeControlButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: calc(var(--control-size-md) - var(--space-1));
height: calc(var(--control-size-md) - var(--space-1));
padding: 0;
border: 1px solid color-mix(in srgb, var(--color-border) 46%, transparent);
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--color-surface) 95%, transparent);
color: var(--color-text-muted);
transition:
border-color 160ms var(--easing-standard),
background 160ms var(--easing-standard),
color 160ms var(--easing-standard);
}
.treeControlButton:hover,
.treeControlButton:focus-visible {
border-color: color-mix(in srgb, var(--color-border-strong) 56%, transparent);
background: var(--color-surface-hover);
color: var(--color-text);
outline: none;
}
.treeList { .treeList {
@include treeNav.tree-list; @include treeNav.tree-list;
} }
@@ -214,6 +261,26 @@
@include treeNav.item-meta; @include treeNav.item-meta;
} }
.dragGhostLayer {
@include treeNav.drag-ghost-layer;
}
.dragGhost {
@include treeNav.drag-ghost;
}
.dragGhostCopy {
@include treeNav.drag-ghost-copy;
}
.dragGhostTitle {
@include treeNav.drag-ghost-title;
}
.dragGhostMeta {
@include treeNav.drag-ghost-meta;
}
.sidebarCollapsed { .sidebarCollapsed {
gap: var(--space-3); gap: var(--space-3);
padding: var(--space-3) var(--space-2); padding: var(--space-3) var(--space-2);
@@ -247,6 +314,7 @@
.sidebarCollapsed .label, .sidebarCollapsed .label,
.sidebarCollapsed .itemMeta, .sidebarCollapsed .itemMeta,
.sidebarCollapsed .treeSectionHeader,
.sidebarCollapsed .treeSectionLabel, .sidebarCollapsed .treeSectionLabel,
.sidebarCollapsed .treeList { .sidebarCollapsed .treeList {
display: none; display: none;
@@ -0,0 +1,287 @@
// Path: Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.parts.tsx
import { For, Show, type JSX } from "solid-js";
import { ChevronRight, Folder } from "../../../lib/icons";
import {
createWorkspaceStaticTarget,
createWorkspaceTreeTarget,
getWorkspaceNodeIcon,
type WorkspaceContextMenuTarget,
type WorkspaceStaticItem,
type WorkspaceTreeNode,
} from "../../app-shell/data/shell.data";
import {
isContextMenuKeyboardTrigger,
type PendingWorkspaceFolderDraft,
type PendingWorkspaceFolderRename,
type WorkspaceDragState,
type WorkspaceDragTarget,
} from "./WorkspaceSidebar.data";
import styles from "./WorkspaceSidebar.module.scss";
export const FolderDraftRow = (props: {
depth: number;
value: string;
onInput: (value: string) => void;
onSubmit: () => void;
onCancel: () => void;
}): JSX.Element => {
let inputRef: HTMLInputElement | undefined;
queueMicrotask(() => inputRef?.focus());
return (
<li>
<div class={styles.treeInputRow} style={{ "--tree-depth": String(props.depth) }}>
<Folder class={styles.icon} size={18} strokeWidth={2} />
<input
ref={inputRef}
type="text"
class={styles.treeInput}
value={props.value}
placeholder="Folder name"
onInput={(event): void => props.onInput(event.currentTarget.value)}
onBlur={props.onSubmit}
onKeyDown={(event): void => {
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
return;
}
if (event.key === "Escape") {
event.preventDefault();
props.onCancel();
event.currentTarget.blur();
}
}}
/>
</div>
</li>
);
};
export const WorkspaceHomeEntry = (props: {
item: WorkspaceStaticItem;
onOpenContextMenu: (event: MouseEvent, target: WorkspaceContextMenuTarget) => void;
onOpenContextMenuFromKeyboard: (element: HTMLElement, target: WorkspaceContextMenuTarget) => void;
}): JSX.Element => {
const Icon = props.item.icon;
const target = createWorkspaceStaticTarget(props.item);
return (
<li>
<button
type="button"
classList={{
[styles.navItem]: true,
[styles.navItemActive]: !!props.item.active,
}}
aria-current={props.item.active ? "page" : undefined}
aria-label={props.item.label}
title={props.item.label}
data-slot="workspace-static-item"
data-target-kind={target.kind}
data-active={props.item.active ? "true" : "false"}
onContextMenu={(event): void => {
event.stopPropagation();
props.onOpenContextMenu(event, target);
}}
onKeyDown={(event): void => {
if (!isContextMenuKeyboardTrigger(event)) {
return;
}
event.preventDefault();
props.onOpenContextMenuFromKeyboard(event.currentTarget, target);
}}
>
<Icon class={styles.icon} size={18} strokeWidth={2} />
<span class={styles.label}>{props.item.label}</span>
<Show when={props.item.meta}>
<span class={styles.itemMeta}>{props.item.meta}</span>
</Show>
</button>
</li>
);
};
export const WorkspaceTreeBranch = (props: {
nodes: readonly WorkspaceTreeNode[];
parentId?: string | null;
depth?: number;
isFolderCollapsed: (folderId: string) => boolean;
onToggleFolder: (folderId: string) => void;
pendingFolderDraft: PendingWorkspaceFolderDraft | null;
pendingFolderName: string;
onPendingFolderNameChange: (value: string) => void;
onSubmitPendingFolder: () => void;
onCancelPendingFolder: () => void;
pendingFolderRename: PendingWorkspaceFolderRename | null;
pendingFolderRenameName: string;
onPendingFolderRenameChange: (value: string) => void;
onSubmitPendingFolderRename: () => void;
onCancelPendingFolderRename: () => void;
onOpenContextMenu: (event: MouseEvent, target: WorkspaceContextMenuTarget) => void;
onOpenContextMenuFromKeyboard: (element: HTMLElement, target: WorkspaceContextMenuTarget) => void;
onNodePointerDown: (event: PointerEvent, nodeId: string) => void;
onNodePointerMove: (event: PointerEvent, parentId: string | null, index: number, node: WorkspaceTreeNode) => void;
dragState: WorkspaceDragState | null;
isTreeClickSuppressed: () => boolean;
}): JSX.Element => {
const depth = () => props.depth ?? 0;
const parentId = () => props.parentId ?? null;
return (
<ul class={styles.treeList} role="list">
<Show when={props.nodes.length === 0 && props.pendingFolderDraft?.parentId !== parentId()}>
<li>
<div class={styles.treeEmptySlot} style={{ "--tree-depth": String(depth()) }} />
</li>
</Show>
<For each={props.nodes}>
{(node, indexAccessor): JSX.Element => {
const Icon = getWorkspaceNodeIcon(node);
const target = createWorkspaceTreeTarget(node);
const isCollapsed = (): boolean => (node.kind === "folder" ? props.isFolderCollapsed(node.id) : false);
const isRenaming = (): boolean => props.pendingFolderRename?.folderId === node.id;
const isDraggedNode = (): boolean => props.dragState?.draggedNodeId === node.id;
const dropIntent = (): WorkspaceDragTarget["intent"] | null => {
if (props.dragState?.dropTarget?.targetNodeId !== node.id) {
return null;
}
return props.dragState.dropTarget.intent;
};
return (
<li>
<Show
when={node.kind === "folder" && isRenaming()}
fallback={
<button
type="button"
classList={{
[styles.treeItem]: true,
[styles.treeItemActive]: !!node.active,
[styles.treeItemFolder]: node.kind === "folder",
[styles.treeItemDragging]: isDraggedNode(),
[styles.treeItemDropBefore]: dropIntent() === "before",
[styles.treeItemDropAfter]: dropIntent() === "after",
[styles.treeItemDropInside]: dropIntent() === "inside",
}}
style={{ "--tree-depth": String(depth()) }}
aria-expanded={node.kind === "folder" ? !isCollapsed() : undefined}
aria-current={node.active ? "page" : undefined}
aria-label={node.label}
title={node.label}
data-slot="workspace-tree-item"
data-kind={node.kind}
data-item-type={node.kind === "item" ? node.itemType : undefined}
data-active={node.active ? "true" : "false"}
onClick={(): void => {
if (props.dragState || props.isTreeClickSuppressed()) {
return;
}
if (node.kind !== "folder") {
return;
}
props.onToggleFolder(node.id);
}}
onContextMenu={(event): void => {
event.stopPropagation();
props.onOpenContextMenu(event, target);
}}
onPointerDown={(event): void => props.onNodePointerDown(event, node.id)}
onPointerMove={(event): void =>
props.onNodePointerMove(event, parentId(), indexAccessor(), node)
}
onPointerEnter={(event): void =>
props.onNodePointerMove(event, parentId(), indexAccessor(), node)
}
onKeyDown={(event): void => {
if (!isContextMenuKeyboardTrigger(event)) {
return;
}
event.preventDefault();
props.onOpenContextMenuFromKeyboard(event.currentTarget, target);
}}
>
<Show when={node.kind === "folder"}>
<ChevronRight
classList={{
[styles.folderChevron]: true,
[styles.folderChevronOpen]: !isCollapsed(),
}}
size={16}
strokeWidth={2}
/>
</Show>
<Icon class={styles.icon} size={18} strokeWidth={2} />
<span class={styles.label}>{node.label}</span>
<Show when={node.meta}>
<span class={styles.itemMeta}>{node.meta}</span>
</Show>
</button>
}
>
<FolderDraftRow
depth={props.pendingFolderRename?.depth ?? depth()}
value={props.pendingFolderRenameName}
onInput={props.onPendingFolderRenameChange}
onSubmit={props.onSubmitPendingFolderRename}
onCancel={props.onCancelPendingFolderRename}
/>
</Show>
<Show
when={
node.kind === "folder" &&
!isCollapsed() &&
(((node.children?.length ?? 0) > 0) || props.pendingFolderDraft?.parentId === node.id)
}
>
<WorkspaceTreeBranch
nodes={node.children ?? []}
parentId={node.id}
depth={depth() + 1}
isFolderCollapsed={props.isFolderCollapsed}
onToggleFolder={props.onToggleFolder}
pendingFolderDraft={props.pendingFolderDraft}
pendingFolderName={props.pendingFolderName}
onPendingFolderNameChange={props.onPendingFolderNameChange}
onSubmitPendingFolder={props.onSubmitPendingFolder}
onCancelPendingFolder={props.onCancelPendingFolder}
pendingFolderRename={props.pendingFolderRename}
pendingFolderRenameName={props.pendingFolderRenameName}
onPendingFolderRenameChange={props.onPendingFolderRenameChange}
onSubmitPendingFolderRename={props.onSubmitPendingFolderRename}
onCancelPendingFolderRename={props.onCancelPendingFolderRename}
onOpenContextMenu={props.onOpenContextMenu}
onOpenContextMenuFromKeyboard={props.onOpenContextMenuFromKeyboard}
onNodePointerDown={props.onNodePointerDown}
onNodePointerMove={props.onNodePointerMove}
dragState={props.dragState}
isTreeClickSuppressed={props.isTreeClickSuppressed}
/>
</Show>
</li>
);
}}
</For>
<Show when={props.pendingFolderDraft && props.pendingFolderDraft.parentId === parentId()}>
<FolderDraftRow
depth={props.pendingFolderDraft?.depth ?? depth()}
value={props.pendingFolderName}
onInput={props.onPendingFolderNameChange}
onSubmit={props.onSubmitPendingFolder}
onCancel={props.onCancelPendingFolder}
/>
</Show>
</ul>
);
};
@@ -0,0 +1,250 @@
// Path: Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.tsx
import { For, Show, type JSX } from "solid-js";
import { Portal } from "solid-js/web";
import { ChevronLeft, ChevronRight, ListCollapse, Plus, UnfoldVertical } from "../../../lib/icons";
import { ProjectSelector } from "../ProjectSelector/ProjectSelector";
import {
getWorkspaceNodeIcon,
workspaceSidebarHeaderActions,
workspaceStaticItems,
} from "../../app-shell/data/shell.data";
import { WorkspaceContextMenu } from "../WorkspaceContextMenu/WorkspaceContextMenu";
import type { WorkspaceSidebarProps } from "./WorkspaceSidebar.data";
import { useWorkspaceSidebar } from "./WorkspaceSidebar.hook";
import { WorkspaceHomeEntry, WorkspaceTreeBranch } from "./WorkspaceSidebar.parts";
import styles from "./WorkspaceSidebar.module.scss";
export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
const railToggleLabel = (): string => (props.railCollapsed ? "Expand server rail" : "Collapse server rail");
const {
contextMenu,
sidebarContextMenuTarget,
isProjectDrawerOpen,
setIsProjectDrawerOpen,
workspaceTreeNodes,
isFolderCollapsed,
pendingFolderDraft,
pendingFolderName,
setPendingFolderName,
submitPendingFolder,
cancelPendingFolder,
pendingFolderRename,
pendingFolderRenameName,
setPendingFolderRenameName,
submitPendingFolderRename,
cancelPendingFolderRename,
handleNodePointerDown,
handleNodePointerMove,
dragState,
suppressNextTreeClick,
draggedNode,
dragGhostPosition,
draggedNodeMeta,
workspaceFolderToggleLabel,
areAllFoldersCollapsed,
totalFolderCount,
handleHeaderActionClick,
openWorkspaceCreateMenu,
handleContextActionSelect,
toggleFolder,
} = useWorkspaceSidebar();
return (
<>
<aside
classList={{
[styles.sidebar]: true,
[styles.sidebarCollapsed]: props.collapsed,
[styles.sidebarDragMode]: !!dragState(),
}}
aria-label="Left workspace sidebar"
data-ui="workspace-sidebar"
data-collapsed={props.collapsed ? "true" : "false"}
onContextMenu={(event): void => {
contextMenu.openMenu(event, sidebarContextMenuTarget());
}}
>
<div
classList={{
[styles.header]: true,
[styles.headerDrawerOpen]: isProjectDrawerOpen(),
}}
data-slot="workspace-sidebar-header"
data-drawer-open={isProjectDrawerOpen() ? "true" : "false"}
>
<div class={styles.headerActions} data-slot="workspace-sidebar-header-actions">
<button
type="button"
classList={{
[styles.headerActionButton]: true,
[styles.headerCollapseButton]: true,
}}
aria-label={railToggleLabel()}
title={railToggleLabel()}
data-slot="workspace-sidebar-rail-toggle"
onClick={props.onToggleRailCollapse}
>
{props.railCollapsed ? <ChevronRight size={16} strokeWidth={2} /> : <ChevronLeft size={16} strokeWidth={2} />}
</button>
<For each={workspaceSidebarHeaderActions}>
{(action): JSX.Element => {
const label =
action.id === "toggle-workspace-folders"
? workspaceFolderToggleLabel()
: action.label;
const Icon =
action.id === "toggle-workspace-folders"
? areAllFoldersCollapsed()
? UnfoldVertical
: ListCollapse
: action.icon;
return (
<button
type="button"
class={styles.headerActionButton}
aria-label={label}
title={label}
data-slot="workspace-sidebar-header-action"
data-action-id={action.id}
disabled={action.id === "toggle-workspace-folders" && totalFolderCount() === 0}
onClick={(): void => handleHeaderActionClick(action.id)}
>
<Icon size={16} strokeWidth={2} />
</button>
);
}}
</For>
</div>
<div class={styles.headerControls} data-slot="workspace-sidebar-header-controls">
<ProjectSelector
compact={props.collapsed}
isOpen={isProjectDrawerOpen()}
onToggle={(): void => {
setIsProjectDrawerOpen(true);
}}
onClose={(): void => {
setIsProjectDrawerOpen(false);
}}
/>
</div>
</div>
<div
classList={{
[styles.section]: true,
[styles.sectionHidden]: isProjectDrawerOpen(),
}}
data-slot="workspace-sidebar-section"
>
<Show when={!props.collapsed}>
<span class={styles.sectionLabel}>Workspace</span>
</Show>
<div class={styles.navScroller} data-slot="workspace-sidebar-nav-scroller">
<ul class={styles.navList} role="list" data-slot="workspace-static-list">
<For each={workspaceStaticItems}>
{(item): JSX.Element => (
<WorkspaceHomeEntry
item={item}
onOpenContextMenu={contextMenu.openMenu}
onOpenContextMenuFromKeyboard={contextMenu.openMenuFromElement}
/>
)}
</For>
</ul>
<Show when={!props.collapsed}>
<div class={styles.treeSectionHeader}>
<div class={styles.treeSectionLabel}>Items</div>
<div class={styles.treeControls}>
<button
type="button"
class={styles.treeControlButton}
onClick={(event): void => {
event.stopPropagation();
openWorkspaceCreateMenu(event.currentTarget);
}}
aria-label="Create in Items"
title="Create"
>
<Plus size={16} strokeWidth={2.25} />
</button>
</div>
</div>
</Show>
<div data-slot="workspace-tree-root">
<WorkspaceTreeBranch
nodes={workspaceTreeNodes()}
parentId={null}
isFolderCollapsed={isFolderCollapsed}
onToggleFolder={toggleFolder}
pendingFolderDraft={pendingFolderDraft()}
pendingFolderName={pendingFolderName()}
onPendingFolderNameChange={setPendingFolderName}
onSubmitPendingFolder={submitPendingFolder}
onCancelPendingFolder={cancelPendingFolder}
pendingFolderRename={pendingFolderRename()}
pendingFolderRenameName={pendingFolderRenameName()}
onPendingFolderRenameChange={setPendingFolderRenameName}
onSubmitPendingFolderRename={submitPendingFolderRename}
onCancelPendingFolderRename={cancelPendingFolderRename}
onOpenContextMenu={contextMenu.openMenu}
onOpenContextMenuFromKeyboard={contextMenu.openMenuFromElement}
onNodePointerDown={handleNodePointerDown}
onNodePointerMove={handleNodePointerMove}
dragState={dragState()}
isTreeClickSuppressed={suppressNextTreeClick}
/>
</div>
</div>
</div>
</aside>
<WorkspaceContextMenu
target={contextMenu.menuState()?.target ?? null}
position={(() => {
const state = contextMenu.menuState();
return state
? {
x: state.x,
y: state.y,
}
: null;
})()}
menuRef={contextMenu.setMenuRef}
onClose={contextMenu.closeMenu}
onSelect={handleContextActionSelect}
/>
<Show when={draggedNode()} keyed>
{(node): JSX.Element => {
const GhostIcon = getWorkspaceNodeIcon(node);
return (
<Portal>
<div class={styles.dragGhostLayer} aria-hidden="true">
<div
class={styles.dragGhost}
style={{
"--drag-ghost-x": `${dragGhostPosition().x}px`,
"--drag-ghost-y": `${dragGhostPosition().y}px`,
}}
>
<GhostIcon class={styles.icon} size={18} strokeWidth={2} />
<div class={styles.dragGhostCopy}>
<div class={styles.dragGhostTitle}>{node.label}</div>
<div class={styles.dragGhostMeta}>{draggedNodeMeta()}</div>
</div>
</div>
</div>
</Portal>
);
}}
</Show>
</>
);
};
@@ -0,0 +1,81 @@
.workspaceTopBar {
width: 100%;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-2);
min-height: calc(var(--control-size-md) - var(--space-3));
padding: var(--space-5) var(--space-6) 0;
}
.workspaceTopBarStart,
.workspaceTopBarEnd {
min-width: calc(var(--control-size-md) - 0.5rem);
display: inline-flex;
align-items: center;
}
.workspaceTopBarEnd {
justify-content: flex-end;
}
.workspaceTopBarCenter {
min-width: 0;
display: flex;
justify-content: center;
}
.workspaceBreadcrumb {
@include text-caption;
min-width: 0;
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.workspaceCollapseButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: calc(var(--control-size-md) - 0.5rem);
height: calc(var(--control-size-md) - 0.5rem);
border: 1px solid color-mix(in srgb, var(--color-border-strong) 44%, transparent);
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--color-surface) 94%, transparent);
color: var(--color-text-muted);
box-shadow: var(--shadow-soft);
transition:
background 160ms var(--easing-standard),
color 160ms var(--easing-standard),
border-color 160ms var(--easing-standard),
transform 180ms var(--easing-standard);
}
.workspaceCollapseButton:hover,
.workspaceCollapseButton:focus-visible {
background: var(--color-surface-hover);
border-color: var(--color-border);
color: var(--color-text);
}
.workspaceCollapseButton:hover {
transform: translateY(-1px);
}
@include respond-down(mobile) {
.workspaceTopBar {
grid-template-columns: minmax(0, 1fr);
padding: var(--space-4) var(--space-4) 0;
}
.workspaceTopBarStart,
.workspaceTopBarEnd,
.workspaceCollapseButton {
display: none;
}
.workspaceTopBarCenter {
justify-content: flex-start;
}
}
@@ -0,0 +1,29 @@
import { type JSX } from "solid-js";
import { ChevronLeft, ChevronRight } from "../../../lib/icons";
import styles from "./WorkspaceTopBar.module.scss";
type WorkspaceTopBarProps = {
sidebarCollapsed: boolean;
breadcrumb: string;
onToggleSidebarCollapse: () => void;
};
export const WorkspaceTopBar = (props: WorkspaceTopBarProps): JSX.Element => {
const sidebarToggleLabel = (): string => (props.sidebarCollapsed ? "Expand left workspace sidebar" : "Collapse left workspace sidebar");
return (
<div class={styles.workspaceTopBar} data-slot="workspace-top-bar">
<div class={styles.workspaceTopBarStart} data-slot="workspace-top-bar-start">
<button type="button" class={styles.workspaceCollapseButton} aria-label={sidebarToggleLabel()} title={sidebarToggleLabel()} data-slot="workspace-sidebar-toggle" onClick={props.onToggleSidebarCollapse}>
{props.sidebarCollapsed ? <ChevronRight size={16} strokeWidth={2} /> : <ChevronLeft size={16} strokeWidth={2} />}
</button>
</div>
<div class={styles.workspaceTopBarCenter} data-slot="workspace-top-bar-center">
<span class={styles.workspaceBreadcrumb}>{props.breadcrumb}</span>
</div>
<div class={styles.workspaceTopBarEnd} data-slot="workspace-top-bar-end" aria-hidden="true" />
</div>
);
};
@@ -57,6 +57,7 @@
display: grid; display: grid;
position: relative; position: relative;
isolation: isolate; isolation: isolate;
cursor: grab;
grid-template-columns: auto auto minmax(0, 1fr) auto; grid-template-columns: auto auto minmax(0, 1fr) auto;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
@@ -71,7 +72,12 @@
transition: transition:
color 160ms var(--easing-standard), color 160ms var(--easing-standard),
box-shadow 160ms var(--easing-standard), box-shadow 160ms var(--easing-standard),
transform 180ms var(--easing-standard); transform 180ms var(--easing-standard),
opacity 160ms var(--easing-standard);
&:active {
cursor: grabbing;
}
&::after { &::after {
content: ""; content: "";
@@ -108,48 +114,61 @@
} }
@mixin item-dragging { @mixin item-dragging {
opacity: 0.45; cursor: grabbing;
transform: scale(0.985); opacity: 0.88;
box-shadow: none; transform: translateX(2px) scale(0.992);
color: var(--color-text);
&::after {
border-color: color-mix(in srgb, var(--color-accent-strong) 24%, var(--color-border));
background: color-mix(in srgb, var(--color-surface) 90%, var(--color-accent-soft) 10%);
box-shadow:
0 10px 24px color-mix(in srgb, black 10%, transparent),
inset 0 1px 0 color-mix(in srgb, white 6%, transparent);
}
}
@mixin item-drop-boundary($edge) {
&::before {
content: "";
position: absolute;
left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4)));
right: var(--space-3);
#{$edge}: calc(-1 * ((8px - 3px) / 2));
height: 8px;
border-radius: 999px;
background:
radial-gradient(circle, color-mix(in srgb, var(--color-accent-strong) 92%, white 8%) 0 3px, transparent 4px)
left center / 8px 8px no-repeat,
linear-gradient(
to right,
color-mix(in srgb, var(--color-accent-strong) 88%, white 12%),
color-mix(in srgb, var(--color-accent-strong) 72%, transparent)
)
center / 100% 3px no-repeat;
pointer-events: none;
z-index: 2;
}
} }
@mixin item-drop-before { @mixin item-drop-before {
&::before { @include item-drop-boundary(top);
content: "";
position: absolute;
left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4)));
right: var(--space-3);
top: calc((var(--space-1) * -0.5) - 1px);
height: 2px;
border-radius: 999px;
background: color-mix(in srgb, var(--color-accent-strong) 78%, transparent);
pointer-events: none;
z-index: 2;
}
} }
@mixin item-drop-after { @mixin item-drop-after {
&::before { @include item-drop-boundary(bottom);
content: "";
position: absolute;
left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4)));
right: var(--space-3);
bottom: calc((var(--space-1) * -0.5) - 1px);
height: 2px;
border-radius: 999px;
background: color-mix(in srgb, var(--color-accent-strong) 78%, transparent);
pointer-events: none;
z-index: 2;
}
} }
@mixin item-drop-inside { @mixin item-drop-inside {
color: var(--color-text); color: var(--color-text);
&::after { &::after {
border-color: color-mix(in srgb, var(--color-accent-strong) 55%, transparent); border-color: color-mix(in srgb, var(--color-accent-strong) 72%, transparent);
background: color-mix(in srgb, var(--color-accent-soft) 36%, var(--color-surface)); background: color-mix(in srgb, var(--color-accent-soft) 48%, var(--color-surface));
box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent); box-shadow:
0 0 0 1px color-mix(in srgb, var(--color-accent-strong) 18%, transparent),
inset 0 1px 0 color-mix(in srgb, white 4%, transparent),
inset 4px 0 0 color-mix(in srgb, var(--color-accent-strong) 78%, transparent);
} }
} }
@@ -186,3 +205,71 @@
@include text-caption; @include text-caption;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
@mixin drag-ghost-layer {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 60;
}
@mixin drag-ghost {
position: fixed;
top: 0;
left: 0;
display: inline-flex;
align-items: center;
gap: var(--space-2);
min-height: calc(var(--control-size-lg) - var(--space-2));
max-width: min(24rem, calc(100vw - (var(--space-6) * 2)));
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-lg);
border: 1px solid color-mix(in srgb, var(--color-accent-strong) 18%, var(--color-border));
background: color-mix(in srgb, var(--color-surface) 92%, var(--color-accent-soft) 8%);
box-shadow:
0 18px 38px color-mix(in srgb, black 16%, transparent),
0 6px 14px color-mix(in srgb, black 8%, transparent),
inset 0 1px 0 color-mix(in srgb, white 8%, transparent);
color: var(--color-text);
transform: translate3d(var(--drag-ghost-x, 0), var(--drag-ghost-y, 0), 0);
will-change: transform;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
&::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(
180deg,
color-mix(in srgb, white 10%, transparent),
transparent 42%
);
pointer-events: none;
}
> * {
position: relative;
z-index: 1;
}
}
@mixin drag-ghost-copy {
min-width: 0;
display: grid;
gap: 0.1rem;
}
@mixin drag-ghost-title {
@include text-label;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@mixin drag-ghost-meta {
@include text-caption;
color: var(--color-text-muted);
}
@@ -0,0 +1,263 @@
import { createEffect, createSignal, type Accessor, type Setter } from "solid-js";
import type { WorkspaceItemTypeId, WorkspaceTreeNode } from "../../app-shell/data/shell.data";
import {
buildPersistedWorkspaceTreeNodes,
findWorkspaceTreeNodeById,
readPersistedWorkspaceTreeNodes,
} from "./workspaceTree.adapters";
import {
createWorkspaceFolderRequest,
createWorkspaceItemRequest,
deleteWorkspaceFolderRequest,
deleteWorkspaceItemRequest,
fetchWorkspaceTree,
isValidWorkspaceProjectId,
moveWorkspaceFolderRequest,
moveWorkspaceItemRequest,
readWorkspaceMutationResponse,
renameWorkspaceFolderRequest,
} from "./workspaceTree.api";
import type {
PersistedWorkspaceTreeNodeRecord,
UseWorkspaceTreeDataOptions,
UseWorkspaceTreeDataResult,
} from "./workspaceTree.types";
export const useWorkspaceTreeData = (
options: UseWorkspaceTreeDataOptions,
): UseWorkspaceTreeDataResult => {
const [persistedNodes, setPersistedNodes] = createSignal<readonly PersistedWorkspaceTreeNodeRecord[]>([]);
const [workspaceTreeNodes, setWorkspaceTreeNodes] = createSignal<readonly WorkspaceTreeNode[]>(
options.fallbackWorkspaceTree(),
);
let latestPersistedTreeRequest = 0;
const syncWorkspaceTree = (): void => {
const nextTree = options.activeProjectId()
? buildPersistedWorkspaceTreeNodes(persistedNodes())
: options.fallbackWorkspaceTree();
setWorkspaceTreeNodes(nextTree);
};
const loadPersistedTree = async (projectId: string): Promise<boolean> => {
const requestId = latestPersistedTreeRequest + 1;
latestPersistedTreeRequest = requestId;
if (!isValidWorkspaceProjectId(projectId)) {
setPersistedNodes([]);
return false;
}
try {
const body = await fetchWorkspaceTree(projectId);
if (requestId !== latestPersistedTreeRequest) {
return false;
}
if (body.error) {
throw new Error(body.message || "Failed to load project tree.");
}
setPersistedNodes(readPersistedWorkspaceTreeNodes(body));
return true;
} catch (error) {
if (requestId !== latestPersistedTreeRequest) {
return false;
}
console.error(error);
setPersistedNodes([]);
return false;
}
};
const resolveFolderPath = (folderId: string): string | null => {
const node = findWorkspaceTreeNodeById(workspaceTreeNodes(), folderId);
return node?.kind === "folder" ? node.path ?? null : null;
};
const resolveItemPath = (itemId: string): string | null => {
const node = findWorkspaceTreeNodeById(workspaceTreeNodes(), itemId);
return node?.kind === "item" ? node.path ?? null : null;
};
const refreshAfterMutation = async (projectId: string, response: Response): Promise<boolean> => {
const body = await readWorkspaceMutationResponse(response);
if (!response.ok) {
throw new Error(body.message || "Failed to persist project tree mutation.");
}
return loadPersistedTree(projectId);
};
const createFolder = async (name: string, parentId: string | null): Promise<boolean> => {
const projectId = options.activeProjectId();
if (!isValidWorkspaceProjectId(projectId)) {
return false;
}
const parentFolderPath = parentId ? resolveFolderPath(parentId) : null;
if (parentId && !parentFolderPath) {
return false;
}
try {
const response = await createWorkspaceFolderRequest(projectId, name, parentFolderPath);
return refreshAfterMutation(projectId, response);
} catch (error) {
console.error(error);
return false;
}
};
const renameFolder = async (folderId: string, name: string): Promise<boolean> => {
const projectId = options.activeProjectId();
const folderPath = resolveFolderPath(folderId);
if (!isValidWorkspaceProjectId(projectId) || !folderPath) {
return false;
}
try {
const response = await renameWorkspaceFolderRequest(projectId, folderPath, name);
return refreshAfterMutation(projectId, response);
} catch (error) {
console.error(error);
return false;
}
};
const deleteFolder = async (folderId: string): Promise<boolean> => {
const projectId = options.activeProjectId();
const folderPath = resolveFolderPath(folderId);
if (!isValidWorkspaceProjectId(projectId) || !folderPath) {
return false;
}
try {
const response = await deleteWorkspaceFolderRequest(projectId, folderPath);
return refreshAfterMutation(projectId, response);
} catch (error) {
console.error(error);
return false;
}
};
const moveFolder = async (folderId: string, parentId: string | null, targetIndex: number): Promise<boolean> => {
const projectId = options.activeProjectId();
const folderPath = resolveFolderPath(folderId);
const parentFolderPath = parentId ? resolveFolderPath(parentId) : null;
if (!isValidWorkspaceProjectId(projectId) || !folderPath) {
return false;
}
try {
const response = await moveWorkspaceFolderRequest(
projectId,
folderPath,
folderId,
parentFolderPath,
parentId,
targetIndex,
);
return refreshAfterMutation(projectId, response);
} catch (error) {
console.error(error);
return false;
}
};
const createItem = async (
name: string,
itemType: WorkspaceItemTypeId,
parentId: string | null,
): Promise<boolean> => {
const projectId = options.activeProjectId();
if (!isValidWorkspaceProjectId(projectId)) {
return false;
}
const parentFolderPath = parentId ? resolveFolderPath(parentId) : null;
if (parentId && !parentFolderPath) {
return false;
}
try {
const response = await createWorkspaceItemRequest(projectId, name, itemType, parentFolderPath);
return refreshAfterMutation(projectId, response);
} catch (error) {
console.error(error);
return false;
}
};
const deleteItem = async (itemId: string): Promise<boolean> => {
const projectId = options.activeProjectId();
const itemPath = resolveItemPath(itemId);
if (!isValidWorkspaceProjectId(projectId) || !itemPath) {
return false;
}
try {
const response = await deleteWorkspaceItemRequest(projectId, itemPath);
return refreshAfterMutation(projectId, response);
} catch (error) {
console.error(error);
return false;
}
};
const moveItem = async (itemId: string, parentId: string | null, targetIndex: number): Promise<boolean> => {
const projectId = options.activeProjectId();
const itemPath = resolveItemPath(itemId);
const parentFolderPath = parentId ? resolveFolderPath(parentId) : null;
if (!isValidWorkspaceProjectId(projectId) || !itemPath) {
return false;
}
try {
const response = await moveWorkspaceItemRequest(
projectId,
itemPath,
itemId,
parentFolderPath,
parentId,
targetIndex,
);
return refreshAfterMutation(projectId, response);
} catch (error) {
console.error(error);
return false;
}
};
createEffect(() => {
syncWorkspaceTree();
});
createEffect(() => {
void loadPersistedTree(options.activeProjectId());
});
return {
workspaceTreeNodes,
setWorkspaceTreeNodes,
resolveFolderPath,
resolveItemPath,
createFolder,
renameFolder,
deleteFolder,
moveFolder,
createItem,
deleteItem,
moveItem,
};
};
@@ -0,0 +1,50 @@
import { Folder } from "../../../lib/icons";
import type { WorkspaceTreeNode } from "../../app-shell/data/shell.data";
import type { PersistedWorkspaceTreeNodeRecord, WorkspaceTreeResponse } from "./workspaceTree.types";
export const buildPersistedWorkspaceTreeNodes = (
nodes: readonly PersistedWorkspaceTreeNodeRecord[],
): WorkspaceTreeNode[] =>
nodes.map((node) =>
node.kind === "folder"
? {
id: node.id,
path: node.path,
label: node.label,
kind: "folder",
icon: Folder,
children: buildPersistedWorkspaceTreeNodes(node.children ?? []),
}
: {
id: node.id,
path: node.path,
label: node.label,
kind: "item",
itemType: node.itemType ?? "core.board.kanban",
}
);
export const readPersistedWorkspaceTreeNodes = (body: WorkspaceTreeResponse): PersistedWorkspaceTreeNodeRecord[] =>
Array.isArray(body.data?.nodes) ? body.data.nodes : [];
export const findWorkspaceTreeNodeById = (
nodes: readonly WorkspaceTreeNode[],
nodeId: string,
): WorkspaceTreeNode | null => {
for (const node of nodes) {
if (node.id === nodeId) {
return node;
}
if (node.kind !== "folder") {
continue;
}
const nestedMatch = findWorkspaceTreeNodeById(node.children ?? [], nodeId);
if (nestedMatch) {
return nestedMatch;
}
}
return null;
};
@@ -0,0 +1,129 @@
import { resolveAPIBase } from "../../../lib/api";
import { isUuidString } from "./navTreeDnd";
import type { WorkspaceItemTypeId } from "../../app-shell/data/shell.data";
import type { WorkspaceMutationResponse, WorkspaceTreeResponse } from "./workspaceTree.types";
export const isValidWorkspaceProjectId = (projectId: string): boolean => Boolean(projectId) && isUuidString(projectId);
const requestWorkspaceTree = async (path: string, init?: RequestInit): Promise<Response> =>
fetch(`${resolveAPIBase()}${path}`, init);
export const fetchWorkspaceTree = async (projectId: string): Promise<WorkspaceTreeResponse> => {
const response = await requestWorkspaceTree(`/projects/${projectId}/tree`, {
headers: {
Accept: "application/json",
},
});
return (await response.json()) as WorkspaceTreeResponse;
};
export const readWorkspaceMutationResponse = async (response: Response): Promise<WorkspaceMutationResponse> =>
(await response.json()) as WorkspaceMutationResponse;
export const createWorkspaceFolderRequest = (projectId: string, name: string, parentFolderId: string | null): Promise<Response> =>
requestWorkspaceTree(`/projects/${projectId}/tree/folders`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
name,
parentFolderId,
}),
});
export const renameWorkspaceFolderRequest = (projectId: string, folderId: string, name: string): Promise<Response> =>
requestWorkspaceTree(`/projects/${projectId}/tree/folders`, {
method: "PATCH",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
folderId,
name,
}),
});
export const deleteWorkspaceFolderRequest = (projectId: string, folderId: string): Promise<Response> =>
requestWorkspaceTree(`/projects/${projectId}/tree/folders?folderId=${encodeURIComponent(folderId)}`, {
method: "DELETE",
headers: {
Accept: "application/json",
},
});
export const moveWorkspaceFolderRequest = (
projectId: string,
folderId: string,
folderNodeId: string,
parentFolderId: string | null,
parentNodeId: string | null,
targetIndex: number,
): Promise<Response> =>
requestWorkspaceTree(`/projects/${projectId}/tree/folders/move`, {
method: "PATCH",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
folderId,
folderNodeId,
parentFolderId,
parentNodeId,
targetIndex,
}),
});
export const createWorkspaceItemRequest = (
projectId: string,
name: string,
itemType: WorkspaceItemTypeId,
parentFolderId: string | null,
): Promise<Response> =>
requestWorkspaceTree(`/projects/${projectId}/tree/items`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
name,
itemType,
parentFolderId,
}),
});
export const deleteWorkspaceItemRequest = (projectId: string, itemId: string): Promise<Response> =>
requestWorkspaceTree(`/projects/${projectId}/tree/items?itemId=${encodeURIComponent(itemId)}`, {
method: "DELETE",
headers: {
Accept: "application/json",
},
});
export const moveWorkspaceItemRequest = (
projectId: string,
itemId: string,
itemNodeId: string,
parentFolderId: string | null,
parentNodeId: string | null,
targetIndex: number,
): Promise<Response> =>
requestWorkspaceTree(`/projects/${projectId}/tree/items/move`, {
method: "PATCH",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
itemId,
itemNodeId,
parentFolderId,
parentNodeId,
targetIndex,
}),
});
@@ -0,0 +1,43 @@
import type { Accessor, Setter } from "solid-js";
import type { WorkspaceItemTypeId, WorkspaceTreeNode } from "../../app-shell/data/shell.data";
export type PersistedWorkspaceTreeNodeRecord = {
id: string;
path: string;
label: string;
kind: "folder" | "item";
itemType?: string;
children?: PersistedWorkspaceTreeNodeRecord[];
};
export type WorkspaceTreeResponse = {
data?: {
nodes?: PersistedWorkspaceTreeNodeRecord[];
};
error?: string;
message?: string;
};
export type WorkspaceMutationResponse = {
error?: string;
message?: string;
};
export type UseWorkspaceTreeDataOptions = {
activeProjectId: Accessor<string>;
fallbackWorkspaceTree: Accessor<readonly WorkspaceTreeNode[]>;
};
export type UseWorkspaceTreeDataResult = {
workspaceTreeNodes: Accessor<readonly WorkspaceTreeNode[]>;
setWorkspaceTreeNodes: Setter<readonly WorkspaceTreeNode[]>;
resolveFolderPath: (folderId: string) => string | null;
resolveItemPath: (itemId: string) => string | null;
createFolder: (name: string, parentId: string | null) => Promise<boolean>;
renameFolder: (folderId: string, name: string) => Promise<boolean>;
deleteFolder: (folderId: string) => Promise<boolean>;
moveFolder: (folderId: string, parentId: string | null, targetIndex: number) => Promise<boolean>;
createItem: (name: string, itemType: WorkspaceItemTypeId, parentId: string | null) => Promise<boolean>;
deleteItem: (itemId: string) => Promise<boolean>;
moveItem: (itemId: string, parentId: string | null, targetIndex: number) => Promise<boolean>;
};
+1 -1
View File
@@ -3,7 +3,7 @@
// @refresh reload // @refresh reload
import { mount, StartClient } from "@solidjs/start/client"; import { mount, StartClient } from "@solidjs/start/client";
import type { JSX } from "solid-js"; import type { JSX } from "solid-js";
import { initializeThemeRuntime } from "./theme/runtime"; import { initializeThemeRuntime } from "./helper/themeRuntime";
const getAppRoot = (): HTMLElement => { const getAppRoot = (): HTMLElement => {
const appRoot = document.getElementById("app"); const appRoot = document.getElementById("app");
+1 -1
View File
@@ -3,7 +3,7 @@
// @refresh reload // @refresh reload
import type { JSX } from "solid-js"; import type { JSX } from "solid-js";
import { createHandler, StartServer } from "@solidjs/start/server"; import { createHandler, StartServer } from "@solidjs/start/server";
import { DEFAULT_THEME, THEME_STORAGE_KEY } from "./theme/runtime"; import { DEFAULT_THEME, THEME_STORAGE_KEY } from "./helper/themeRuntime";
import { THEME_MODE_NAMES } from "./theme/schema"; import { THEME_MODE_NAMES } from "./theme/schema";
const themeBootstrapScript = ` const themeBootstrapScript = `
@@ -1,3 +1,5 @@
// Path: Frontend/src/helper/createLongPressGesture.ts
import type { JSX } from "solid-js"; import type { JSX } from "solid-js";
type PointerHandler = NonNullable<JSX.DOMAttributes<Element>["onPointerDown"]>; type PointerHandler = NonNullable<JSX.DOMAttributes<Element>["onPointerDown"]>;
@@ -1,7 +1,13 @@
// Path: Frontend/src/theme/runtime.ts // Path: Frontend/src/helper/themeRuntime.ts
import { defaultThemePresetMeta, defaultThemePresetPath, resolveThemePresetPath } from "./presets"; import { defaultThemePresetMeta, defaultThemePresetPath, resolveThemePresetPath } from "../theme/presets";
import { createCssVariableMap, isThemeModeName, validateThemeDefinition, type ThemeDefinition, type ThemeModeName } from "./schema"; import {
createCssVariableMap,
isThemeModeName,
validateThemeDefinition,
type ThemeDefinition,
type ThemeModeName,
} from "../theme/schema";
export type Theme = ThemeModeName; export type Theme = ThemeModeName;