Compare commits

...

11 Commits

Author SHA1 Message Date
MangoPig
6ba04effcf Feat: Hydrate shell from app state 2026-06-19 17:39:39 +01:00
MangoPig
913825f596 Feat: Add bootstrap persistence and shell routes 2026-06-19 17:39:21 +01:00
MangoPig
93ce3e07f0 Merge branch 'Features/Frontend/Future-Model-Prep' 2026-06-18 16:58:53 +01:00
MangoPig
25c6934801 Feat: Prepare frontend future model 2026-06-18 16:58:31 +01:00
MangoPig
fcf96590bb Merge branch 'Features/Frontend/Context-Menu' 2026-06-18 11:17:23 +01:00
MangoPig
eeba19bbb6 Feat: Add workspace context actions 2026-06-18 11:16:54 +01:00
MangoPig
dea9e7e6ff Merge branch 'Features/Frontend/Responsiveness' 2026-06-17 10:52:39 +01:00
MangoPig
85bf971547 Feat: Add responsive workspace shell 2026-06-17 10:52:14 +01:00
MangoPig
5d86a5124b Merge branch 'Features/Frontend/CollapsibleShell' into tmp/collapsible-shell-clean-merge 2026-06-17 05:42:48 +01:00
MangoPig
7fdc5f2d22 Feat: Add collapsible shell 2026-06-17 05:37:29 +01:00
MangoPig
630b3778db Merge branch 'Features/Frontend/Notifications' 2026-06-16 17:00:51 +01:00
50 changed files with 5943 additions and 403 deletions

2
.gitignore vendored
View File

@@ -25,3 +25,5 @@ pnpm-debug.log*
# Go build output
tmp/
bin/
.cgcignore

View File

@@ -0,0 +1,180 @@
-- +goose Up
CREATE TYPE instance_mode AS ENUM ('personal', 'organizational');
CREATE TYPE instance_access AS ENUM ('local', 'remote');
CREATE TYPE instance_protocol AS ENUM ('http', 'https');
CREATE TYPE workspace_kind AS ENUM ('organization', 'department', 'team', 'project');
CREATE TYPE membership_role AS ENUM ('owner', 'admin', 'member');
CREATE TABLE IF NOT EXISTS installations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
singleton BOOLEAN NOT NULL DEFAULT TRUE UNIQUE,
mode instance_mode NOT NULL,
access instance_access NOT NULL,
protocol instance_protocol NOT NULL DEFAULT 'http',
host TEXT NOT NULL,
is_bootstrapped BOOLEAN NOT NULL DEFAULT FALSE,
bootstrapped_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
is_instance_admin BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique ON users (LOWER(email));
CREATE TABLE IF NOT EXISTS user_homes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE organizations
ADD COLUMN IF NOT EXISTS created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL;
CREATE TABLE IF NOT EXISTS organization_memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role membership_role NOT NULL DEFAULT 'member',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (organization_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_organization_memberships_user_id ON organization_memberships (user_id);
CREATE TABLE IF NOT EXISTS departments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
slug TEXT NOT NULL,
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (organization_id, slug)
);
CREATE INDEX IF NOT EXISTS idx_departments_organization_id ON departments (organization_id);
CREATE TABLE IF NOT EXISTS teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
department_id UUID REFERENCES departments(id) ON DELETE SET NULL,
name TEXT NOT NULL,
slug TEXT NOT NULL,
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (organization_id, slug)
);
CREATE INDEX IF NOT EXISTS idx_teams_organization_id ON teams (organization_id);
CREATE INDEX IF NOT EXISTS idx_teams_department_id ON teams (department_id);
CREATE TABLE IF NOT EXISTS team_memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role membership_role NOT NULL DEFAULT 'member',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (team_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_team_memberships_user_id ON team_memberships (user_id);
CREATE TABLE IF NOT EXISTS projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
department_id UUID REFERENCES departments(id) ON DELETE SET NULL,
team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
name TEXT NOT NULL,
slug TEXT NOT NULL,
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (organization_id, slug)
);
CREATE INDEX IF NOT EXISTS idx_projects_organization_id ON projects (organization_id);
CREATE INDEX IF NOT EXISTS idx_projects_department_id ON projects (department_id);
CREATE INDEX IF NOT EXISTS idx_projects_team_id ON projects (team_id);
CREATE TABLE IF NOT EXISTS project_memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role membership_role NOT NULL DEFAULT 'member',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_project_memberships_user_id ON project_memberships (user_id);
ALTER TABLE workspaces
ADD COLUMN IF NOT EXISTS kind workspace_kind NOT NULL DEFAULT 'organization',
ADD COLUMN IF NOT EXISTS created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS department_id UUID REFERENCES departments(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS project_id UUID REFERENCES projects(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_workspaces_department_id ON workspaces (department_id);
CREATE INDEX IF NOT EXISTS idx_workspaces_team_id ON workspaces (team_id);
CREATE INDEX IF NOT EXISTS idx_workspaces_project_id ON workspaces (project_id);
-- +goose Down
DROP INDEX IF EXISTS idx_workspaces_project_id;
DROP INDEX IF EXISTS idx_workspaces_team_id;
DROP INDEX IF EXISTS idx_workspaces_department_id;
ALTER TABLE workspaces
DROP COLUMN IF EXISTS project_id,
DROP COLUMN IF EXISTS team_id,
DROP COLUMN IF EXISTS department_id,
DROP COLUMN IF EXISTS created_by_user_id,
DROP COLUMN IF EXISTS kind;
DROP INDEX IF EXISTS idx_project_memberships_user_id;
DROP TABLE IF EXISTS project_memberships;
DROP INDEX IF EXISTS idx_projects_team_id;
DROP INDEX IF EXISTS idx_projects_department_id;
DROP INDEX IF EXISTS idx_projects_organization_id;
DROP TABLE IF EXISTS projects;
DROP INDEX IF EXISTS idx_team_memberships_user_id;
DROP TABLE IF EXISTS team_memberships;
DROP INDEX IF EXISTS idx_teams_department_id;
DROP INDEX IF EXISTS idx_teams_organization_id;
DROP TABLE IF EXISTS teams;
DROP INDEX IF EXISTS idx_departments_organization_id;
DROP TABLE IF EXISTS departments;
DROP INDEX IF EXISTS idx_organization_memberships_user_id;
DROP TABLE IF EXISTS organization_memberships;
ALTER TABLE organizations
DROP COLUMN IF EXISTS created_by_user_id;
DROP TABLE IF EXISTS user_homes;
DROP INDEX IF EXISTS idx_users_email_unique;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS installations;
DROP TYPE IF EXISTS membership_role;
DROP TYPE IF EXISTS workspace_kind;
DROP TYPE IF EXISTS instance_protocol;
DROP TYPE IF EXISTS instance_access;
DROP TYPE IF EXISTS instance_mode;

View File

@@ -23,14 +23,50 @@ target "dev-image" {
tags = ["${REGISTRY}/moku/work-backend:dev-${TAG}"]
}
target "prod-api" {
inherits = ["_app"]
target = "runtime"
args = {
SERVICE_NAME = "api"
}
tags = ["moku/work-backend:local-prod-api"]
}
target "prod-worker" {
inherits = ["_app"]
target = "runtime"
args = {
SERVICE_NAME = "worker"
}
tags = ["moku/work-backend:local-prod-worker"]
}
target "prod-api-image" {
inherits = ["_app"]
target = "runtime"
args = {
SERVICE_NAME = "api"
}
tags = ["${REGISTRY}/moku/work-backend:prod-api-${TAG}"]
}
target "prod-worker-image" {
inherits = ["_app"]
target = "runtime"
args = {
SERVICE_NAME = "worker"
}
tags = ["${REGISTRY}/moku/work-backend:prod-worker-${TAG}"]
}
group "local" {
targets = ["dev"]
targets = ["dev", "prod-api", "prod-worker"]
}
group "registry" {
targets = ["dev-image"]
targets = ["dev-image", "prod-api-image", "prod-worker-image"]
}
group "default" {
targets = ["dev"]
targets = ["dev", "prod-api", "prod-worker"]
}

View File

@@ -0,0 +1,863 @@
// Path: Backend/internal/bootstrap/service.go
package bootstrap
import (
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"moku-backend/internal/database"
)
const (
primaryOrganizationSlug = "primary-organization"
primaryDepartmentSlug = "primary-department"
primaryTeamSlug = "primary-team"
primaryProjectSlug = "primary-project"
organizationWorkspaceSlug = "organization-home"
departmentWorkspaceSlug = "department-home"
teamWorkspaceSlug = "team-home"
projectWorkspaceSlug = "project-home"
defaultInstallationHost = "localhost"
defaultInstallationMode = "personal"
defaultInstallationAccess = "local"
defaultInstallationProtocol = "http"
defaultOrganizationName = "Moku"
defaultPersonalServerSuffix = "Personal"
defaultPersonalDisplayName = "Personal"
bootstrapWorkspaceKindOrg = "organization"
bootstrapWorkspaceKindDept = "department"
bootstrapWorkspaceKindTeam = "team"
bootstrapWorkspaceKindProject = "project"
)
var (
ErrInstallationNotConfigured = errors.New("bootstrap installation step has not been completed")
ErrAdminNotConfigured = errors.New("bootstrap admin step has not been completed")
)
type Service struct {
db *database.DB
}
type SaveInstanceInput struct {
Protocol string
Access string
Host string
}
type SaveModeInput struct {
Mode string
}
type SaveAdminInput struct {
DisplayName string
Email string
Password string
}
type SaveStructureInput struct {
OrganizationName string
DepartmentName string
TeamName string
ProjectName string
}
type InstallationRecord struct {
ID string `json:"id"`
Mode string `json:"mode"`
Access string `json:"access"`
Protocol string `json:"protocol"`
Host string `json:"host"`
IsBootstrapped bool `json:"isBootstrapped"`
}
type AdminRecord struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
IsInstanceAdmin bool `json:"isInstanceAdmin"`
HomeTitle string `json:"homeTitle"`
}
type OrganizationRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
}
type DepartmentRecord struct {
ID string `json:"id"`
OrganizationID string `json:"organizationId"`
Name string `json:"name"`
Slug string `json:"slug"`
}
type TeamRecord struct {
ID string `json:"id"`
OrganizationID string `json:"organizationId"`
DepartmentID *string `json:"departmentId,omitempty"`
Name string `json:"name"`
Slug string `json:"slug"`
}
type ProjectRecord struct {
ID string `json:"id"`
OrganizationID string `json:"organizationId"`
DepartmentID *string `json:"departmentId,omitempty"`
TeamID *string `json:"teamId,omitempty"`
Name string `json:"name"`
Slug string `json:"slug"`
}
type WorkspaceRecord struct {
ID string `json:"id"`
OrganizationID string `json:"organizationId"`
Name string `json:"name"`
Slug string `json:"slug"`
Kind string `json:"kind"`
DepartmentID *string `json:"departmentId,omitempty"`
TeamID *string `json:"teamId,omitempty"`
ProjectID *string `json:"projectId,omitempty"`
}
type StructureRecord struct {
Installation InstallationRecord `json:"installation"`
Organization namedRecord `json:"organization"`
Department namedRecord `json:"department"`
Team namedRecord `json:"team"`
Project namedRecord `json:"project"`
Admin AdminSummary `json:"admin"`
}
type AdminSummary struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
}
type BootstrapStructureState struct {
Organization *OrganizationRecord `json:"organization,omitempty"`
Department *DepartmentRecord `json:"department,omitempty"`
Team *TeamRecord `json:"team,omitempty"`
Project *ProjectRecord `json:"project,omitempty"`
Workspaces []WorkspaceRecord `json:"workspaces"`
}
type BootstrapState struct {
Installation *InstallationRecord `json:"installation,omitempty"`
Admin *AdminRecord `json:"admin,omitempty"`
Structure BootstrapStructureState `json:"structure"`
}
type AppShellState struct {
Installation *InstallationRecord `json:"installation,omitempty"`
Admin *AdminRecord `json:"admin,omitempty"`
Organizations []OrganizationRecord `json:"organizations"`
Departments []DepartmentRecord `json:"departments"`
Teams []TeamRecord `json:"teams"`
Projects []ProjectRecord `json:"projects"`
Workspaces []WorkspaceRecord `json:"workspaces"`
}
type namedRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
}
func NewService(db *database.DB) *Service {
return &Service{db: db}
}
func (service *Service) SaveInstance(ctx context.Context, input SaveInstanceInput) (InstallationRecord, error) {
row := service.db.Pool.QueryRow(ctx, `
INSERT INTO installations (singleton, mode, access, protocol, host)
VALUES (
TRUE,
COALESCE((SELECT mode FROM installations WHERE singleton = TRUE LIMIT 1), 'personal'::instance_mode),
$1::instance_access,
$2::instance_protocol,
$3
)
ON CONFLICT (singleton) DO UPDATE
SET
access = EXCLUDED.access,
protocol = EXCLUDED.protocol,
host = EXCLUDED.host,
updated_at = NOW()
RETURNING id::text, mode::text, access::text, protocol::text, host, is_bootstrapped;
`, input.Access, input.Protocol, input.Host)
return scanInstallationRecord(row)
}
func (service *Service) SaveMode(ctx context.Context, input SaveModeInput) (InstallationRecord, error) {
row := service.db.Pool.QueryRow(ctx, `
INSERT INTO installations (singleton, mode, access, protocol, host)
VALUES (
TRUE,
$1::instance_mode,
COALESCE((SELECT access FROM installations WHERE singleton = TRUE LIMIT 1), 'local'::instance_access),
COALESCE((SELECT protocol FROM installations WHERE singleton = TRUE LIMIT 1), 'http'::instance_protocol),
COALESCE((SELECT host FROM installations WHERE singleton = TRUE LIMIT 1), $2)
)
ON CONFLICT (singleton) DO UPDATE
SET
mode = EXCLUDED.mode,
updated_at = NOW()
RETURNING id::text, mode::text, access::text, protocol::text, host, is_bootstrapped;
`, input.Mode, defaultInstallationHost)
return scanInstallationRecord(row)
}
func (service *Service) SaveAdmin(ctx context.Context, input SaveAdminInput) (AdminRecord, error) {
tx, err := service.db.Pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return AdminRecord{}, err
}
defer func() {
_ = tx.Rollback(ctx)
}()
if _, err := tx.Exec(ctx, `
UPDATE users
SET is_instance_admin = FALSE, updated_at = NOW()
WHERE is_instance_admin = TRUE;
`); err != nil {
return AdminRecord{}, err
}
var record AdminRecord
if err := tx.QueryRow(ctx, `
INSERT INTO users (email, display_name, password_hash, is_instance_admin)
VALUES ($1, $2, crypt($3, gen_salt('bf')), TRUE)
ON CONFLICT ((LOWER(email))) DO UPDATE
SET
email = EXCLUDED.email,
display_name = EXCLUDED.display_name,
password_hash = crypt($3, gen_salt('bf')),
is_instance_admin = TRUE,
updated_at = NOW()
RETURNING id::text, email, display_name, is_instance_admin;
`, input.Email, input.DisplayName, input.Password).Scan(
&record.ID,
&record.Email,
&record.DisplayName,
&record.IsInstanceAdmin,
); err != nil {
return AdminRecord{}, err
}
record.HomeTitle = personalHomeTitle(record.DisplayName)
if err := tx.QueryRow(ctx, `
INSERT INTO user_homes (user_id, title)
VALUES ($1::uuid, $2)
ON CONFLICT (user_id) DO UPDATE
SET title = EXCLUDED.title, updated_at = NOW()
RETURNING title;
`, record.ID, record.HomeTitle).Scan(&record.HomeTitle); err != nil {
return AdminRecord{}, err
}
if err := tx.Commit(ctx); err != nil {
return AdminRecord{}, err
}
return record, nil
}
func (service *Service) SaveStructure(ctx context.Context, input SaveStructureInput) (StructureRecord, error) {
tx, err := service.db.Pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return StructureRecord{}, err
}
defer func() {
_ = tx.Rollback(ctx)
}()
installation, err := loadInstallation(ctx, tx)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return StructureRecord{}, ErrInstallationNotConfigured
}
return StructureRecord{}, err
}
admin, err := loadPrimaryAdmin(ctx, tx)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return StructureRecord{}, ErrAdminNotConfigured
}
return StructureRecord{}, err
}
organizationName := strings.TrimSpace(input.OrganizationName)
if organizationName == "" {
organizationName = defaultRootOrganizationName(installation.Mode, installation.Host, admin.DisplayName)
}
organization, err := upsertNamedRecord(ctx, tx, `
INSERT INTO organizations (name, slug, created_by_user_id)
VALUES ($1, $2, $3::uuid)
ON CONFLICT (slug) DO UPDATE
SET name = EXCLUDED.name, created_by_user_id = EXCLUDED.created_by_user_id, updated_at = NOW()
RETURNING id::text, name, slug;
`, organizationName, primaryOrganizationSlug, admin.ID)
if err != nil {
return StructureRecord{}, err
}
if _, err := tx.Exec(ctx, `
INSERT INTO organization_memberships (organization_id, user_id, role)
VALUES ($1::uuid, $2::uuid, 'owner'::membership_role)
ON CONFLICT (organization_id, user_id) DO UPDATE
SET role = EXCLUDED.role;
`, organization.ID, admin.ID); err != nil {
return StructureRecord{}, err
}
department, err := upsertNamedRecord(ctx, tx, `
INSERT INTO departments (organization_id, name, slug, created_by_user_id)
VALUES ($1::uuid, $2, $3, $4::uuid)
ON CONFLICT (organization_id, slug) DO UPDATE
SET name = EXCLUDED.name, created_by_user_id = EXCLUDED.created_by_user_id, updated_at = NOW()
RETURNING id::text, name, slug;
`, organization.ID, input.DepartmentName, primaryDepartmentSlug, admin.ID)
if err != nil {
return StructureRecord{}, err
}
team, err := upsertNamedRecord(ctx, tx, `
INSERT INTO teams (organization_id, department_id, name, slug, created_by_user_id)
VALUES ($1::uuid, $2::uuid, $3, $4, $5::uuid)
ON CONFLICT (organization_id, slug) DO UPDATE
SET department_id = EXCLUDED.department_id, name = EXCLUDED.name, created_by_user_id = EXCLUDED.created_by_user_id, updated_at = NOW()
RETURNING id::text, name, slug;
`, organization.ID, department.ID, input.TeamName, primaryTeamSlug, admin.ID)
if err != nil {
return StructureRecord{}, err
}
if _, err := tx.Exec(ctx, `
INSERT INTO team_memberships (team_id, user_id, role)
VALUES ($1::uuid, $2::uuid, 'owner'::membership_role)
ON CONFLICT (team_id, user_id) DO UPDATE
SET role = EXCLUDED.role;
`, team.ID, admin.ID); err != nil {
return StructureRecord{}, err
}
project, err := upsertNamedRecord(ctx, tx, `
INSERT INTO projects (organization_id, department_id, team_id, name, slug, created_by_user_id)
VALUES ($1::uuid, $2::uuid, $3::uuid, $4, $5, $6::uuid)
ON CONFLICT (organization_id, slug) DO UPDATE
SET department_id = EXCLUDED.department_id, team_id = EXCLUDED.team_id, name = EXCLUDED.name, created_by_user_id = EXCLUDED.created_by_user_id, updated_at = NOW()
RETURNING id::text, name, slug;
`, organization.ID, department.ID, team.ID, input.ProjectName, primaryProjectSlug, admin.ID)
if err != nil {
return StructureRecord{}, err
}
if _, err := tx.Exec(ctx, `
INSERT INTO project_memberships (project_id, user_id, role)
VALUES ($1::uuid, $2::uuid, 'owner'::membership_role)
ON CONFLICT (project_id, user_id) DO UPDATE
SET role = EXCLUDED.role;
`, project.ID, admin.ID); err != nil {
return StructureRecord{}, err
}
if err := upsertWorkspace(ctx, tx, organization.ID, organization.Name, organizationWorkspaceSlug, bootstrapWorkspaceKindOrg, admin.ID, nil, nil, nil); err != nil {
return StructureRecord{}, err
}
if err := upsertWorkspace(ctx, tx, organization.ID, department.Name, departmentWorkspaceSlug, bootstrapWorkspaceKindDept, admin.ID, &department.ID, nil, nil); err != nil {
return StructureRecord{}, err
}
if err := upsertWorkspace(ctx, tx, organization.ID, team.Name, teamWorkspaceSlug, bootstrapWorkspaceKindTeam, admin.ID, &department.ID, &team.ID, nil); err != nil {
return StructureRecord{}, err
}
if err := upsertWorkspace(ctx, tx, organization.ID, project.Name, projectWorkspaceSlug, bootstrapWorkspaceKindProject, admin.ID, &department.ID, &team.ID, &project.ID); err != nil {
return StructureRecord{}, err
}
installation, err = updateBootstrappedInstallation(ctx, tx)
if err != nil {
return StructureRecord{}, err
}
if err := tx.Commit(ctx); err != nil {
return StructureRecord{}, err
}
return StructureRecord{
Installation: installation,
Organization: organization,
Department: department,
Team: team,
Project: project,
Admin: admin,
}, nil
}
func (service *Service) GetInstallation(ctx context.Context) (*InstallationRecord, error) {
record, err := scanInstallationRecord(service.db.Pool.QueryRow(ctx, `
SELECT id::text, mode::text, access::text, protocol::text, host, is_bootstrapped
FROM installations
WHERE singleton = TRUE
LIMIT 1;
`))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &record, nil
}
func (service *Service) GetAdmin(ctx context.Context) (*AdminRecord, error) {
var record AdminRecord
err := service.db.Pool.QueryRow(ctx, `
SELECT
u.id::text,
u.email,
u.display_name,
u.is_instance_admin,
COALESCE(uh.title, '')
FROM users u
LEFT JOIN user_homes uh ON uh.user_id = u.id
WHERE u.is_instance_admin = TRUE
ORDER BY u.created_at ASC
LIMIT 1;
`).Scan(
&record.ID,
&record.Email,
&record.DisplayName,
&record.IsInstanceAdmin,
&record.HomeTitle,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &record, nil
}
func (service *Service) GetStructure(ctx context.Context) (BootstrapStructureState, error) {
workspaces, err := service.listWorkspaces(ctx)
if err != nil {
return BootstrapStructureState{}, err
}
organization, err := service.loadPrimaryOrganization(ctx)
if err != nil {
return BootstrapStructureState{}, err
}
department, err := service.loadPrimaryDepartment(ctx)
if err != nil {
return BootstrapStructureState{}, err
}
team, err := service.loadPrimaryTeam(ctx)
if err != nil {
return BootstrapStructureState{}, err
}
project, err := service.loadPrimaryProject(ctx)
if err != nil {
return BootstrapStructureState{}, err
}
return BootstrapStructureState{
Organization: organization,
Department: department,
Team: team,
Project: project,
Workspaces: workspaces,
}, nil
}
func (service *Service) GetState(ctx context.Context) (BootstrapState, error) {
installation, err := service.GetInstallation(ctx)
if err != nil {
return BootstrapState{}, err
}
admin, err := service.GetAdmin(ctx)
if err != nil {
return BootstrapState{}, err
}
structure, err := service.GetStructure(ctx)
if err != nil {
return BootstrapState{}, err
}
return BootstrapState{
Installation: installation,
Admin: admin,
Structure: structure,
}, nil
}
func (service *Service) GetAppShellState(ctx context.Context) (AppShellState, error) {
installation, err := service.GetInstallation(ctx)
if err != nil {
return AppShellState{}, err
}
admin, err := service.GetAdmin(ctx)
if err != nil {
return AppShellState{}, err
}
organizations, err := service.listOrganizations(ctx)
if err != nil {
return AppShellState{}, err
}
departments, err := service.listDepartments(ctx)
if err != nil {
return AppShellState{}, err
}
teams, err := service.listTeams(ctx)
if err != nil {
return AppShellState{}, err
}
projects, err := service.listProjects(ctx)
if err != nil {
return AppShellState{}, err
}
workspaces, err := service.listWorkspaces(ctx)
if err != nil {
return AppShellState{}, err
}
return AppShellState{
Installation: installation,
Admin: admin,
Organizations: organizations,
Departments: departments,
Teams: teams,
Projects: projects,
Workspaces: workspaces,
}, nil
}
func scanInstallationRecord(row pgx.Row) (InstallationRecord, error) {
var record InstallationRecord
if err := row.Scan(&record.ID, &record.Mode, &record.Access, &record.Protocol, &record.Host, &record.IsBootstrapped); err != nil {
return InstallationRecord{}, err
}
return record, nil
}
func (service *Service) loadPrimaryOrganization(ctx context.Context) (*OrganizationRecord, error) {
var record OrganizationRecord
err := service.db.Pool.QueryRow(ctx, `
SELECT id::text, name, slug
FROM organizations
ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC
LIMIT 1;
`, primaryOrganizationSlug).Scan(&record.ID, &record.Name, &record.Slug)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &record, nil
}
func (service *Service) loadPrimaryDepartment(ctx context.Context) (*DepartmentRecord, error) {
var record DepartmentRecord
err := service.db.Pool.QueryRow(ctx, `
SELECT id::text, organization_id::text, name, slug
FROM departments
ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC
LIMIT 1;
`, primaryDepartmentSlug).Scan(&record.ID, &record.OrganizationID, &record.Name, &record.Slug)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &record, nil
}
func (service *Service) loadPrimaryTeam(ctx context.Context) (*TeamRecord, error) {
var record TeamRecord
err := service.db.Pool.QueryRow(ctx, `
SELECT id::text, organization_id::text, department_id::text, name, slug
FROM teams
ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC
LIMIT 1;
`, primaryTeamSlug).Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.Name, &record.Slug)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &record, nil
}
func (service *Service) loadPrimaryProject(ctx context.Context) (*ProjectRecord, error) {
var record ProjectRecord
err := service.db.Pool.QueryRow(ctx, `
SELECT id::text, organization_id::text, department_id::text, team_id::text, name, slug
FROM projects
ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC
LIMIT 1;
`, primaryProjectSlug).Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.TeamID, &record.Name, &record.Slug)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &record, nil
}
func (service *Service) listOrganizations(ctx context.Context) ([]OrganizationRecord, error) {
rows, err := service.db.Pool.Query(ctx, `
SELECT id::text, name, slug
FROM organizations
ORDER BY created_at ASC;
`)
if err != nil {
return nil, err
}
defer rows.Close()
var records []OrganizationRecord
for rows.Next() {
var record OrganizationRecord
if err := rows.Scan(&record.ID, &record.Name, &record.Slug); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (service *Service) listDepartments(ctx context.Context) ([]DepartmentRecord, error) {
rows, err := service.db.Pool.Query(ctx, `
SELECT id::text, organization_id::text, name, slug
FROM departments
ORDER BY created_at ASC;
`)
if err != nil {
return nil, err
}
defer rows.Close()
var records []DepartmentRecord
for rows.Next() {
var record DepartmentRecord
if err := rows.Scan(&record.ID, &record.OrganizationID, &record.Name, &record.Slug); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (service *Service) listTeams(ctx context.Context) ([]TeamRecord, error) {
rows, err := service.db.Pool.Query(ctx, `
SELECT id::text, organization_id::text, department_id::text, name, slug
FROM teams
ORDER BY created_at ASC;
`)
if err != nil {
return nil, err
}
defer rows.Close()
var records []TeamRecord
for rows.Next() {
var record TeamRecord
if err := rows.Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.Name, &record.Slug); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (service *Service) listProjects(ctx context.Context) ([]ProjectRecord, error) {
rows, err := service.db.Pool.Query(ctx, `
SELECT id::text, organization_id::text, department_id::text, team_id::text, name, slug
FROM projects
ORDER BY created_at ASC;
`)
if err != nil {
return nil, err
}
defer rows.Close()
var records []ProjectRecord
for rows.Next() {
var record ProjectRecord
if err := rows.Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.TeamID, &record.Name, &record.Slug); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (service *Service) listWorkspaces(ctx context.Context) ([]WorkspaceRecord, error) {
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
FROM workspaces
ORDER BY created_at ASC;
`)
if err != nil {
return nil, err
}
defer rows.Close()
var records []WorkspaceRecord
for rows.Next() {
var record WorkspaceRecord
if err := rows.Scan(&record.ID, &record.OrganizationID, &record.Name, &record.Slug, &record.Kind, &record.DepartmentID, &record.TeamID, &record.ProjectID); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func loadInstallation(ctx context.Context, tx pgx.Tx) (InstallationRecord, error) {
return scanInstallationRecord(tx.QueryRow(ctx, `
SELECT id::text, mode::text, access::text, protocol::text, host, is_bootstrapped
FROM installations
WHERE singleton = TRUE
LIMIT 1;
`))
}
func loadPrimaryAdmin(ctx context.Context, tx pgx.Tx) (AdminSummary, error) {
var admin AdminSummary
if err := tx.QueryRow(ctx, `
SELECT id::text, email, display_name
FROM users
WHERE is_instance_admin = TRUE
ORDER BY created_at ASC
LIMIT 1;
`).Scan(&admin.ID, &admin.Email, &admin.DisplayName); err != nil {
return AdminSummary{}, err
}
return admin, nil
}
func updateBootstrappedInstallation(ctx context.Context, tx pgx.Tx) (InstallationRecord, error) {
return scanInstallationRecord(tx.QueryRow(ctx, `
UPDATE installations
SET is_bootstrapped = TRUE, bootstrapped_at = COALESCE(bootstrapped_at, NOW()), updated_at = NOW()
WHERE singleton = TRUE
RETURNING id::text, mode::text, access::text, protocol::text, host, is_bootstrapped;
`))
}
func upsertNamedRecord(ctx context.Context, tx pgx.Tx, query string, args ...any) (namedRecord, error) {
var record namedRecord
if err := tx.QueryRow(ctx, query, args...).Scan(&record.ID, &record.Name, &record.Slug); err != nil {
return namedRecord{}, err
}
return record, nil
}
func upsertWorkspace(ctx context.Context, tx pgx.Tx, organizationID, name, slug, kind, createdByUserID string, departmentID, teamID, projectID *string) error {
_, err := tx.Exec(ctx, `
INSERT INTO workspaces (organization_id, name, slug, kind, created_by_user_id, department_id, team_id, project_id)
VALUES ($1::uuid, $2, $3, $4::workspace_kind, $5::uuid, $6::uuid, $7::uuid, $8::uuid)
ON CONFLICT (organization_id, slug) DO UPDATE
SET
name = EXCLUDED.name,
kind = EXCLUDED.kind,
created_by_user_id = EXCLUDED.created_by_user_id,
department_id = EXCLUDED.department_id,
team_id = EXCLUDED.team_id,
project_id = EXCLUDED.project_id,
updated_at = NOW();
`, organizationID, name, slug, kind, createdByUserID, departmentID, teamID, projectID)
return err
}
func defaultRootOrganizationName(mode, host, adminDisplayName string) string {
trimmedHost := strings.TrimSpace(host)
trimmedAdminDisplayName := strings.TrimSpace(adminDisplayName)
if strings.EqualFold(mode, defaultInstallationMode) {
if trimmedAdminDisplayName != "" {
return fmt.Sprintf("%s %s", trimmedAdminDisplayName, defaultPersonalServerSuffix)
}
return defaultPersonalDisplayName
}
if trimmedHost != "" {
return trimmedHost
}
return defaultOrganizationName
}
func personalHomeTitle(displayName string) string {
trimmedDisplayName := strings.TrimSpace(displayName)
if trimmedDisplayName == "" {
return "Home"
}
if strings.HasSuffix(strings.ToLower(trimmedDisplayName), "s") {
return fmt.Sprintf("%s' Home", trimmedDisplayName)
}
return fmt.Sprintf("%s's Home", trimmedDisplayName)
}

View File

@@ -15,7 +15,6 @@ type Config struct {
LogLevel string
WebPort string
APIPort string
WorkerPort string
PostgresURL string
ValkeyURL string
ShutdownTimeout time.Duration
@@ -28,7 +27,6 @@ func Load() *Config {
LogLevel: getEnv("LOG_LEVEL", "debug"),
WebPort: getEnv("BACKEND_WEB_PORT", "8080"),
APIPort: getEnv("BACKEND_API_PORT", "8081"),
WorkerPort: getEnv("BACKEND_WORKER_PORT", "8082"),
PostgresURL: getEnv("DATABASE_URL", "postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable"),
ValkeyURL: getEnv("VALKEY_URL", "redis://localhost:6379/0"),
ShutdownTimeout: getDurationEnv("BACKEND_SHUTDOWN_TIMEOUT", 10*time.Second),
@@ -43,8 +41,6 @@ func (c *Config) Address(serviceName string) string {
port = c.WebPort
case "api":
port = c.APIPort
case "worker":
port = c.WorkerPort
default:
port = c.WebPort
}

View File

@@ -0,0 +1,363 @@
// Path: Backend/internal/httpx/api_bootstrap_routes.go
package httpx
import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
bootstrapservice "moku-backend/internal/bootstrap"
)
type bootstrapInstanceStepRequest struct {
Protocol string `json:"protocol"`
Access string `json:"access"`
Host string `json:"host"`
}
type bootstrapModeStepRequest struct {
Mode string `json:"mode"`
}
type bootstrapAdminStepRequest struct {
DisplayName string `json:"displayName"`
Email string `json:"email"`
Password string `json:"password"`
}
type bootstrapStructureStepRequest struct {
OrganizationName string `json:"organizationName"`
DepartmentName string `json:"departmentName"`
TeamName string `json:"teamName"`
ProjectName string `json:"projectName"`
}
func (routes apiRoutes) handleBootstrapOverview(w http.ResponseWriter, _ *http.Request) {
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"resource": "bootstrap",
"status": "persisted",
"steps": []map[string]string{
{
"id": "instance",
"method": http.MethodPost,
"path": "/v1/bootstrap/steps/instance",
},
{
"id": "mode",
"method": http.MethodPost,
"path": "/v1/bootstrap/steps/mode",
},
{
"id": "admin",
"method": http.MethodPost,
"path": "/v1/bootstrap/steps/admin",
},
{
"id": "structure",
"method": http.MethodPost,
"path": "/v1/bootstrap/steps/structure",
},
{
"id": "installation",
"method": http.MethodGet,
"path": "/v1/bootstrap/installation",
},
{
"id": "admin-state",
"method": http.MethodGet,
"path": "/v1/bootstrap/admin",
},
{
"id": "structure-state",
"method": http.MethodGet,
"path": "/v1/bootstrap/structure",
},
{
"id": "bootstrap-state",
"method": http.MethodGet,
"path": "/v1/bootstrap/state",
},
{
"id": "app-shell",
"method": http.MethodGet,
"path": "/v1/app-shell",
},
},
},
})
}
func (routes apiRoutes) handleBootstrapInstallation(w http.ResponseWriter, r *http.Request) {
record, err := routes.bootstrapService().GetInstallation(r.Context())
if err != nil {
routes.writeBootstrapPersistenceError(w, r, err)
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": record,
"meta": map[string]any{
"resource": "bootstrap-installation",
},
})
}
func (routes apiRoutes) handleBootstrapAdmin(w http.ResponseWriter, r *http.Request) {
record, err := routes.bootstrapService().GetAdmin(r.Context())
if err != nil {
routes.writeBootstrapPersistenceError(w, r, err)
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": record,
"meta": map[string]any{
"resource": "bootstrap-admin",
},
})
}
func (routes apiRoutes) handleBootstrapStructure(w http.ResponseWriter, r *http.Request) {
record, err := routes.bootstrapService().GetStructure(r.Context())
if err != nil {
routes.writeBootstrapPersistenceError(w, r, err)
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": record,
"meta": map[string]any{
"resource": "bootstrap-structure",
},
})
}
func (routes apiRoutes) handleBootstrapState(w http.ResponseWriter, r *http.Request) {
record, err := routes.bootstrapService().GetState(r.Context())
if err != nil {
routes.writeBootstrapPersistenceError(w, r, err)
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": record,
"meta": map[string]any{
"resource": "bootstrap-state",
},
})
}
func (routes apiRoutes) handleAppShellState(w http.ResponseWriter, r *http.Request) {
record, err := routes.bootstrapService().GetAppShellState(r.Context())
if err != nil {
routes.writeBootstrapPersistenceError(w, r, err)
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": record,
"meta": map[string]any{
"resource": "app-shell",
},
})
}
func (routes apiRoutes) handleBootstrapInstanceStep(w http.ResponseWriter, r *http.Request) {
payload, ok := decodeBootstrapRequest[bootstrapInstanceStepRequest](w, r)
if !ok {
return
}
payload.Protocol = strings.ToLower(strings.TrimSpace(payload.Protocol))
payload.Access = strings.ToLower(strings.TrimSpace(payload.Access))
payload.Host = strings.TrimSpace(payload.Host)
if payload.Protocol != "http" && payload.Protocol != "https" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Protocol must be either 'http' or 'https'.")
return
}
if payload.Access != "local" && payload.Access != "remote" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Access must be either 'local' or 'remote'.")
return
}
if payload.Host == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Host is required.")
return
}
record, err := routes.bootstrapService().SaveInstance(r.Context(), bootstrapservice.SaveInstanceInput{
Protocol: payload.Protocol,
Access: payload.Access,
Host: payload.Host,
})
if err != nil {
routes.writeBootstrapPersistenceError(w, r, err)
return
}
routes.writeBootstrapStepResponse(w, http.StatusOK, "instance", map[string]any{
"request": payload,
"installation": record,
})
}
func (routes apiRoutes) handleBootstrapModeStep(w http.ResponseWriter, r *http.Request) {
payload, ok := decodeBootstrapRequest[bootstrapModeStepRequest](w, r)
if !ok {
return
}
payload.Mode = strings.ToLower(strings.TrimSpace(payload.Mode))
if payload.Mode != "personal" && payload.Mode != "organizational" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Mode must be either 'personal' or 'organizational'.")
return
}
record, err := routes.bootstrapService().SaveMode(r.Context(), bootstrapservice.SaveModeInput{Mode: payload.Mode})
if err != nil {
routes.writeBootstrapPersistenceError(w, r, err)
return
}
routes.writeBootstrapStepResponse(w, http.StatusOK, "mode", map[string]any{
"request": payload,
"installation": record,
})
}
func (routes apiRoutes) handleBootstrapAdminStep(w http.ResponseWriter, r *http.Request) {
payload, ok := decodeBootstrapRequest[bootstrapAdminStepRequest](w, r)
if !ok {
return
}
payload.DisplayName = strings.TrimSpace(payload.DisplayName)
payload.Email = strings.ToLower(strings.TrimSpace(payload.Email))
if payload.DisplayName == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Display name is required.")
return
}
if payload.Email == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Email is required.")
return
}
if strings.TrimSpace(payload.Password) == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Password is required.")
return
}
record, err := routes.bootstrapService().SaveAdmin(r.Context(), bootstrapservice.SaveAdminInput{
DisplayName: payload.DisplayName,
Email: payload.Email,
Password: payload.Password,
})
if err != nil {
routes.writeBootstrapPersistenceError(w, r, err)
return
}
routes.writeBootstrapStepResponse(w, http.StatusOK, "admin", map[string]any{
"request": payload,
"admin": record,
})
}
func (routes apiRoutes) handleBootstrapStructureStep(w http.ResponseWriter, r *http.Request) {
payload, ok := decodeBootstrapRequest[bootstrapStructureStepRequest](w, r)
if !ok {
return
}
payload.OrganizationName = strings.TrimSpace(payload.OrganizationName)
payload.DepartmentName = strings.TrimSpace(payload.DepartmentName)
payload.TeamName = strings.TrimSpace(payload.TeamName)
payload.ProjectName = strings.TrimSpace(payload.ProjectName)
if payload.DepartmentName == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Department name is required.")
return
}
if payload.TeamName == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Team name is required.")
return
}
if payload.ProjectName == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project name is required.")
return
}
record, err := routes.bootstrapService().SaveStructure(r.Context(), bootstrapservice.SaveStructureInput{
OrganizationName: payload.OrganizationName,
DepartmentName: payload.DepartmentName,
TeamName: payload.TeamName,
ProjectName: payload.ProjectName,
})
if err != nil {
routes.writeBootstrapPersistenceError(w, r, err)
return
}
routes.writeBootstrapStepResponse(w, http.StatusOK, "structure", map[string]any{
"request": payload,
"structure": record,
})
}
func (routes apiRoutes) bootstrapService() *bootstrapservice.Service {
return bootstrapservice.NewService(routes.cfg.Database)
}
func (routes apiRoutes) writeBootstrapStepResponse(w http.ResponseWriter, status int, step string, payload any) {
WriteJSON(w, status, map[string]any{
"data": map[string]any{
"step": step,
"result": payload,
},
"meta": map[string]any{
"resource": "bootstrap-step",
"persisted": true,
},
})
}
func (routes apiRoutes) writeBootstrapPersistenceError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, bootstrapservice.ErrInstallationNotConfigured), errors.Is(err, bootstrapservice.ErrAdminNotConfigured):
WriteError(w, http.StatusConflict, RequestIDFromContext(r.Context()), "bootstrap_prerequisite_missing", err.Error())
default:
routes.cfg.Logger.Error("persist bootstrap step", "error", err, "path", r.URL.Path)
WriteError(w, http.StatusInternalServerError, RequestIDFromContext(r.Context()), "bootstrap_persist_failed", "Failed to persist bootstrap data.")
}
}
func decodeBootstrapRequest[T any](w http.ResponseWriter, r *http.Request) (T, bool) {
var payload T
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 for this bootstrap step.")
return payload, false
}
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must be valid JSON for this bootstrap step.")
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
}

View File

@@ -19,6 +19,18 @@ func newAPIRoutes(cfg RouterConfig) routeRegistrar {
func (routes apiRoutes) Register(router chi.Router) {
router.Route("/v1", func(apiRouter chi.Router) {
apiRouter.Get("/", routes.handleIndex)
apiRouter.Get("/bootstrap", routes.handleBootstrapOverview)
apiRouter.Get("/bootstrap/installation", routes.handleBootstrapInstallation)
apiRouter.Get("/bootstrap/admin", routes.handleBootstrapAdmin)
apiRouter.Get("/bootstrap/structure", routes.handleBootstrapStructure)
apiRouter.Get("/bootstrap/state", routes.handleBootstrapState)
apiRouter.Route("/bootstrap/steps", func(bootstrapRouter chi.Router) {
bootstrapRouter.Post("/instance", routes.handleBootstrapInstanceStep)
bootstrapRouter.Post("/mode", routes.handleBootstrapModeStep)
bootstrapRouter.Post("/admin", routes.handleBootstrapAdminStep)
bootstrapRouter.Post("/structure", routes.handleBootstrapStructureStep)
})
apiRouter.Get("/app-shell", routes.handleAppShellState)
apiRouter.Get("/organizations", routes.handleOrganizations)
apiRouter.Get("/workspaces", routes.handleWorkspaces)
})

View File

@@ -1,11 +1,15 @@
project_root := justfile_directory()
proxy_bake := project_root + "/Proxy/docker-bake.hcl"
backend_bake := project_root + "/Backend/docker-bake.hcl"
local_compose := project_root + "/Docker/docker-compose.local.prod.yaml"
proxy_image := "moku/work-proxy:local-prod"
backend_api_image := "moku/work-backend:local-prod-api"
backend_worker_image := "moku/work-backend:local-prod-worker"
# Build the local production proxy image locally.
build:
cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' prod
cd '{{project_root}}' && docker buildx bake -f '{{backend_bake}}' prod-api prod-worker
# Start the local production stack in the background using the current image.
up:
@@ -17,6 +21,7 @@ start: build up
# Rebuild the local production proxy image locally.
rebuild:
cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' --set '*.no-cache=true' prod
cd '{{project_root}}' && docker buildx bake -f '{{backend_bake}}' --set '*.no-cache=true' prod-api prod-worker
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate
# Stop and remove the local production stack.
@@ -35,3 +40,5 @@ restart:
clean:
docker compose -f '{{local_compose}}' down --remove-orphans --volumes
docker image rm -f '{{proxy_image}}' >/dev/null 2>&1 || true
docker image rm -f '{{backend_api_image}}' >/dev/null 2>&1 || true
docker image rm -f '{{backend_worker_image}}' >/dev/null 2>&1 || true

View File

@@ -1,7 +1,67 @@
x-backend-service: &backend-service
restart: unless-stopped
env_file:
- ../Env/.env.local
environment:
DATABASE_URL: postgres://moku:moku_dev_password@postgres:5432/moku?sslmode=disable
VALKEY_URL: redis://valkey:6379/0
depends_on:
postgres:
condition: service_healthy
valkey:
condition: service_healthy
services:
proxy:
image: moku/work-proxy:local-prod
container_name: moku-work-proxy-local
restart: unless-stopped
ports:
- "8080:80"
postgres:
image: postgres:17-alpine
container_name: moku-work-postgres-prod
restart: unless-stopped
environment:
POSTGRES_DB: moku
POSTGRES_USER: moku
POSTGRES_PASSWORD: moku_dev_password
volumes:
- moku_work_postgres_prod_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U moku -d moku"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
valkey:
image: valkey/valkey:8-alpine
container_name: moku-work-valkey-prod
restart: unless-stopped
volumes:
- moku_work_valkey_prod_data:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
api:
<<: *backend-service
image: moku/work-backend:local-prod-api
container_name: moku-work-backend-api-prod
worker:
<<: *backend-service
image: moku/work-backend:local-prod-worker
container_name: moku-work-backend-worker-prod
proxy:
image: moku/work-proxy:local-prod
container_name: moku-work-proxy-local
restart: unless-stopped
depends_on:
api:
condition: service_started
ports:
- "8080:80"
volumes:
moku_work_postgres_prod_data:
moku_work_valkey_prod_data:

View File

@@ -6,8 +6,13 @@ LOG_LEVEL=debug
BACKEND_WEB_PORT=8080
BACKEND_API_PORT=8081
BACKEND_WORKER_PORT=8082
BACKEND_SHUTDOWN_TIMEOUT=10s
DATABASE_URL=postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable
VALKEY_URL=redis://localhost:6379/0
VITE_API_BASE_URL=/v1
# Local frontend dev uses the Vite proxy for /v1 requests.
# Override only if the browser must call the API directly:
# VITE_API_BASE_URL=http://localhost:8081/v1

View File

@@ -3,6 +3,7 @@
import type { JSX } from "solid-js";
import { AppShell } from "./components/shell/AppShell/AppShell";
import "./styles/main.scss";
import "./styles/user-overrides.scss";
const App = (): JSX.Element => {
return <AppShell />;

View File

@@ -8,8 +8,10 @@
}
.body {
--shell-dock-clearance: calc(var(--space-12) + var(--space-12) + var(--space-8));
--rail-width: 4.75rem;
--sidebar-width: 16.75rem;
--mobile-bottom-nav-clearance: 0rem;
--shell-top-left-radius: calc(var(--radius-xl) + var(--space-1));
--shell-frame-border: color-mix(in srgb, var(--color-border-strong) 44%, transparent);
--shell-divider-border: color-mix(in srgb, var(--color-border-strong) 34%, transparent);
@@ -23,6 +25,14 @@
background: var(--color-surface);
}
.bodyRailCollapsed {
--rail-width: 0rem;
}
.bodySidebarCollapsed {
--sidebar-width: 0rem;
}
.railColumn {
min-height: 0;
display: flex;
@@ -93,11 +103,21 @@
border-top-right-radius: 0;
}
.mobileWorkspaceView {
min-width: 0;
min-height: 0;
height: 100%;
display: grid;
box-sizing: border-box;
overflow: hidden;
background: var(--workspace-panel-surface);
}
.sidebarDock {
position: absolute;
bottom: var(--space-3);
left: calc(var(--space-1) + (var(--rail-width) * 0.1));
width: calc(var(--sidebar-width) + (var(--rail-width) * 0.9) - var(--space-2));
width: max(12rem, calc(var(--sidebar-width) + (var(--rail-width) * 0.9) - var(--space-2)));
right: auto;
z-index: calc(var(--z-modal) + 1);
pointer-events: none;
@@ -112,6 +132,14 @@
--rail-width: 5rem;
--sidebar-width: 17.25rem;
}
.bodyRailCollapsed {
--rail-width: 0rem;
}
.bodySidebarCollapsed {
--sidebar-width: 0rem;
}
}
@include respond-down(tablet) {
@@ -119,21 +147,102 @@
--rail-width: 4.5rem;
--sidebar-width: 13.25rem;
}
.bodyRailCollapsed {
--rail-width: 0rem;
}
.bodySidebarCollapsed {
--sidebar-width: 0rem;
}
}
.bodyRailCollapsed .railColumn {
overflow: hidden;
}
.bodySidebarCollapsed .railColumn {
--rail-dock-clearance: 0rem;
}
.bodySidebarCollapsed:not(.bodyRailCollapsed) .railColumn {
--rail-bottom-offset: var(--space-3);
}
.bodySidebarCollapsed .sidebarColumn {
--sidebar-dock-clearance: 0rem;
display: none;
overflow: hidden;
border-left-width: 0;
border-top-width: 0;
}
.bodySidebarCollapsed .workspaceRegion {
grid-template-columns: minmax(0, 1fr);
}
.bodySidebarCollapsed .workspaceMain {
border-left-color: transparent;
}
.bodySidebarCollapsed:not(.bodyRailCollapsed) .workspaceMain {
border-top-width: 1px;
border-top-color: var(--shell-frame-border);
border-left-color: var(--shell-frame-border);
border-top-left-radius: var(--shell-top-left-radius);
box-shadow: none;
}
.bodySidebarCollapsed:not(.bodyRailCollapsed) .workspaceRegion::before {
display: none;
}
.bodySidebarCollapsed .sidebarDock {
display: none;
}
@include respond-down(mobile) {
.body {
grid-template-columns: 4.5rem minmax(0, 1fr);
--rail-width: 4.5rem;
grid-template-columns: minmax(0, 1fr);
--rail-width: 0rem;
--sidebar-width: 0rem;
--mobile-bottom-nav-clearance: calc(var(--space-12) + var(--space-10) + env(safe-area-inset-bottom, 0px));
}
.railColumn {
position: sticky;
top: 0;
display: none;
}
.workspaceRegion,
.workspaceRegion {
display: grid;
grid-template-columns: minmax(0, 1fr);
border-top-left-radius: 0;
}
.workspaceRegion::before {
background: var(--workspace-panel-surface);
border-left-width: 0;
border-right-width: 0;
border-top-left-radius: 0;
}
.sidebarColumn,
.sidebarDock {
display: none;
}
.workspaceMain {
border-left-width: 0;
border-top-right-radius: 0;
border-top-left-radius: 0;
box-sizing: border-box;
padding-bottom: var(--mobile-bottom-nav-clearance);
}
.mobileWorkspaceView {
height: 100%;
max-height: none;
padding-bottom: 0;
background: var(--workspace-panel-surface);
}
}

View File

@@ -1,19 +1,54 @@
// Path: Frontend/src/components/shell/AppShell/AppShell.tsx
import { createSignal, onMount, type JSX } from "solid-js";
import { createSignal, onCleanup, onMount, Show, type JSX } from "solid-js";
import { getDocumentTheme, setTheme, type Theme } from "../../../theme/runtime";
import { WorkspaceHome } from "../../workspace-home/WorkspaceHome/WorkspaceHome";
import { AppShellDataProvider, useAppShellData } from "../data/app-shell.context";
import { LeftRail } from "../LeftRail/LeftRail";
import { MobileBottomNav } from "../MobileBottomNav/MobileBottomNav";
import { MobileWorkspaceBrowser } from "../MobileWorkspaceBrowser/MobileWorkspaceBrowser";
import { ServerDock } from "../ServerDock/ServerDock";
import { NotificationsMenu } from "../TopBar/NotificationsMenu";
import { ProfileMenu } from "../TopBar/ProfileMenu";
import { TopBar } from "../TopBar/TopBar";
import { WorkspaceSidebar } from "../WorkspaceSidebar/WorkspaceSidebar";
import styles from "./AppShell.module.scss";
export const AppShell = (): JSX.Element => {
type MobileWorkspaceView = "notifications" | "profile" | null;
const MOBILE_VIEWPORT_QUERY = "(max-width: 48rem)";
const AppShellContent = (): JSX.Element => {
const [themeState, setThemeState] = createSignal<Theme>("light");
const [isRailCollapsed, setIsRailCollapsed] = createSignal(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = createSignal(false);
const [isMobileViewport, setIsMobileViewport] = createSignal(false);
const [isMobileWorkspaceBrowserOpen, setIsMobileWorkspaceBrowserOpen] = createSignal(false);
const [activeMobileWorkspaceView, setActiveMobileWorkspaceView] = createSignal<MobileWorkspaceView>(null);
const appShellData = useAppShellData();
onMount((): void => {
setThemeState(getDocumentTheme());
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return;
}
const mediaQuery = window.matchMedia(MOBILE_VIEWPORT_QUERY);
const syncMobileViewport = (): void => {
setIsMobileViewport(mediaQuery.matches);
if (!mediaQuery.matches) {
setIsMobileWorkspaceBrowserOpen(false);
setActiveMobileWorkspaceView(null);
}
};
syncMobileViewport();
mediaQuery.addEventListener("change", syncMobileViewport);
onCleanup(() => {
mediaQuery.removeEventListener("change", syncMobileViewport);
});
});
const toggleTheme = (): void => {
@@ -23,32 +58,118 @@ export const AppShell = (): JSX.Element => {
setThemeState(next);
};
return (
<div class={styles.shell}>
<TopBar theme={themeState()} onToggleTheme={toggleTheme} />
const openMobileWorkspaceView = (view: Exclude<MobileWorkspaceView, null>): void => {
setIsMobileWorkspaceBrowserOpen(false);
setActiveMobileWorkspaceView((current) => (current === view ? null : view));
};
<div class={styles.body}>
const toggleMobileWorkspaceBrowser = (): void => {
setActiveMobileWorkspaceView(null);
setIsMobileWorkspaceBrowserOpen((open) => !open);
};
const toggleMobileNotifications = (): void => {
openMobileWorkspaceView("notifications");
};
const toggleMobileProfile = (): void => {
openMobileWorkspaceView("profile");
};
const closeMobileWorkspaceView = (): void => {
setActiveMobileWorkspaceView(null);
};
return (
<div class={styles.shell} data-ui="app-shell" data-app-shell-status={appShellData.status()}>
<TopBar
theme={themeState()}
onToggleTheme={toggleTheme}
isMobileViewport={isMobileViewport()}
isNotificationsOpen={activeMobileWorkspaceView() === "notifications"}
isProfileOpen={activeMobileWorkspaceView() === "profile"}
onToggleNotifications={toggleMobileNotifications}
onToggleProfile={toggleMobileProfile}
/>
<div
classList={{
[styles.body]: true,
[styles.bodyRailCollapsed]: isRailCollapsed(),
[styles.bodySidebarCollapsed]: isSidebarCollapsed(),
}}
data-slot="shell-body"
data-rail-collapsed={isRailCollapsed() ? "true" : "false"}
data-sidebar-collapsed={isSidebarCollapsed() ? "true" : "false"}
>
{/* Left server rail */}
<div class={styles.railColumn}>
<LeftRail />
<div class={styles.railColumn} data-slot="rail-column">
<LeftRail collapsed={isRailCollapsed()} />
</div>
{/* Sidebar + main workspace frame */}
<div class={styles.workspaceRegion}>
<div class={styles.sidebarColumn}>
<WorkspaceSidebar />
<div class={styles.workspaceRegion} data-slot="workspace-region">
<div class={styles.sidebarColumn} data-slot="sidebar-column">
<WorkspaceSidebar
collapsed={isSidebarCollapsed()}
railCollapsed={isRailCollapsed()}
onToggleRailCollapse={(): void => {
setIsRailCollapsed((collapsed) => !collapsed);
}}
/>
</div>
<div class={styles.workspaceMain}>
<WorkspaceHome />
<div class={styles.workspaceMain} data-slot="workspace-main">
{/* On mobile, top-bar menus become full workspace views instead of popovers. */}
<Show
when={isMobileViewport() && activeMobileWorkspaceView() !== null}
fallback={
<WorkspaceHome
sidebarCollapsed={isSidebarCollapsed()}
onToggleSidebarCollapse={(): void => {
setIsSidebarCollapsed((collapsed) => !collapsed);
}}
/>
}
>
<div class={styles.mobileWorkspaceView} data-slot="mobile-workspace-view" data-view={activeMobileWorkspaceView() ?? undefined}>
<Show when={activeMobileWorkspaceView() === "notifications"}>
<NotificationsMenu id="mobile-workspace-notifications" onSelect={closeMobileWorkspaceView} variant="workspace" />
</Show>
<Show when={activeMobileWorkspaceView() === "profile"}>
<ProfileMenu id="mobile-workspace-profile" onSelect={closeMobileWorkspaceView} variant="workspace" />
</Show>
</div>
</Show>
</div>
</div>
{/* Floating server dock overlay */}
<div class={styles.sidebarDock}>
<div class={styles.sidebarDock} data-slot="sidebar-dock">
<ServerDock />
</div>
</div>
<MobileBottomNav
isBrowseOpen={isMobileWorkspaceBrowserOpen()}
onBrowseToggle={(): void => {
toggleMobileWorkspaceBrowser();
}}
/>
<MobileWorkspaceBrowser
open={isMobileWorkspaceBrowserOpen()}
onClose={(): void => {
setIsMobileWorkspaceBrowserOpen(false);
}}
/>
</div>
);
};
export const AppShell = (): JSX.Element => {
return (
<AppShellDataProvider>
<AppShellContent />
</AppShellDataProvider>
);
};

View File

@@ -7,7 +7,7 @@
min-width: 0;
padding: 0;
display: inline-flex;
align-items: center;
align-items: flex-end;
justify-content: flex-start;
gap: var(--space-2);
border: 0;
@@ -43,14 +43,25 @@
box-shadow: 0 0 0 0.12rem color-mix(in srgb, var(--color-accent-soft) 14%, transparent);
}
.copy {
min-width: 0;
display: inline-flex;
align-items: baseline;
gap: var(--space-1);
}
.value {
@include text-title;
color: var(--color-text);
font-weight: var(--font-weight-title);
line-height: 1;
}
.meta {
@include text-caption;
color: var(--color-text-muted);
line-height: 1;
padding-left: var(--space-1);
}
.icon {
@@ -80,7 +91,7 @@
.menuSection {
display: grid;
gap: 0.15rem;
gap: calc(var(--space-1) / 2);
}
.menuSectionLabel {
@@ -171,14 +182,39 @@
}
.submenuIndicator {
width: 0.35rem;
height: 0.35rem;
width: calc(var(--space-1) + (var(--space-1) / 2));
height: calc(var(--space-1) + (var(--space-1) / 2));
flex: 0 0 auto;
border-radius: 999px;
background: var(--color-accent-soft);
}
@include respond-down(mobile) {
.selector {
align-items: flex-end;
gap: var(--space-1);
}
.copy {
gap: var(--space-2);
}
.value {
line-height: 0.95;
}
.meta {
font-size: 0.68rem;
line-height: 1;
letter-spacing: 0.01em;
padding-bottom: calc(var(--space-1) / 2);
}
.icon {
align-self: flex-end;
margin-bottom: calc(var(--space-1) / 2);
}
.menu {
min-width: min(16rem, calc(100vw - (var(--space-4) * 2)));
}

View File

@@ -1,22 +1,29 @@
import { For, 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 { activeDepartment, departmentItems, type DepartmentItem } from "../data/shell.data";
import { useAppShellData } from "../data/app-shell.context";
import { type DepartmentItem } from "../data/shell.data";
import styles from "./DepartmentSelector.module.scss";
const defaultDepartment = departmentItems.find((item) => item.id === activeDepartment.id) ?? departmentItems[0];
const defaultTeamName = departmentItems
.find((item) => item.id === activeDepartment.id)
?.teams.find((teamName) => teamName === activeDepartment.teamName)
?? defaultDepartment?.teams[0]
?? "";
export const DepartmentSelector = (): JSX.Element => {
const appShellData = useAppShellData();
const [isOpen, setIsOpen] = createSignal(false);
const [selectedDepartment, setSelectedDepartment] = createSignal<DepartmentItem>(defaultDepartment);
const [selectedTeamName, setSelectedTeamName] = createSignal(defaultTeamName);
const [selectedDepartment, setSelectedDepartment] = createSignal<DepartmentItem>(appShellData.departmentItems()[0] ?? appShellData.activeDepartment());
const [selectedTeamName, setSelectedTeamName] = createSignal(appShellData.activeDepartment().teamName);
let rootRef: HTMLDivElement | undefined;
createEffect(() => {
const activeDepartment = appShellData.activeDepartment();
const matchingDepartment = appShellData.departmentItems().find((item) => item.id === activeDepartment.id) ?? appShellData.departmentItems()[0];
if (!matchingDepartment) {
return;
}
setSelectedDepartment(matchingDepartment);
setSelectedTeamName(activeDepartment.teamName || matchingDepartment.teams[0] || "");
});
onMount(() => {
const handlePointerDown = (event: PointerEvent): void => {
if (!isOpen()) return;
@@ -53,8 +60,10 @@ export const DepartmentSelector = (): JSX.Element => {
aria-expanded={isOpen()}
onClick={() => setIsOpen((open) => !open)}
>
<strong class={styles.value}>{selectedDepartment().name}</strong>
<span class={styles.meta}>{selectedTeamName()} team</span>
<span class={styles.copy}>
<strong class={styles.value}>{selectedDepartment().name}</strong>
<span class={styles.meta}>{selectedTeamName()}</span>
</span>
<ChevronDown classList={{ [styles.icon]: true, [styles.iconOpen]: isOpen() }} size={16} strokeWidth={2} />
</button>
@@ -64,7 +73,7 @@ export const DepartmentSelector = (): JSX.Element => {
<div class={styles.menuSection}>
<span class={styles.menuSectionLabel}>Departments</span>
<For each={departmentItems}>
<For each={appShellData.departmentItems()}>
{(item): JSX.Element => (
<button
classList={{ [styles.menuItem]: true, [styles.menuItemActive]: item.id === selectedDepartment().id }}

View File

@@ -1,7 +1,5 @@
.rail {
--rail-workspace-size: var(--control-size-lg);
--rail-action-size: var(--control-size-md);
--rail-dock-clearance: 8rem;
position: relative;
z-index: 3;
flex: 1;
@@ -10,12 +8,19 @@
flex-direction: column;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-2) calc(var(--space-3) + var(--rail-dock-clearance));
padding: var(--space-3) var(--space-2) calc(var(--space-3) + var(--rail-dock-clearance, var(--shell-dock-clearance)));
overflow: visible;
}
.topCluster,
.bottomCluster {
.railCollapsed {
--rail-workspace-size: calc(var(--control-size-md) + 0.1rem);
justify-content: flex-start;
gap: 0;
padding-top: var(--space-4);
padding-inline: var(--space-1);
}
.topCluster {
width: 100%;
display: flex;
flex-direction: column;
@@ -23,14 +28,18 @@
gap: var(--space-2);
}
.bottomCluster {
margin-top: auto;
}
.topCluster {
gap: var(--space-3);
}
.railCollapsed .topCluster {
gap: var(--space-3);
}
.railCollapsed .topCluster {
align-items: center;
}
.items {
width: 100%;
min-height: 0;
@@ -173,20 +182,3 @@
border-radius: var(--radius-md);
box-shadow: none;
}
.addButton {
width: var(--rail-action-size);
height: var(--rail-action-size);
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px dashed var(--color-border-strong);
border-radius: var(--radius-pill);
background: transparent;
color: var(--color-text-muted);
&:hover {
background: var(--color-surface-hover);
color: var(--color-text);
}
}

View File

@@ -1,8 +1,8 @@
// Path: Frontend/src/components/shell/LeftRail/LeftRail.tsx
import { For, type JSX } from "solid-js";
import { Plus } from "../../../lib/icons";
import { railItems, type RailItem } from "../data/shell.data";
import { For, Show, type JSX } from "solid-js";
import { useAppShellData } from "../data/app-shell.context";
import { type RailItem } from "../data/shell.data";
import styles from "./LeftRail.module.scss";
type RailEntryProps = {
@@ -42,29 +42,42 @@ const RailEntry = (props: RailEntryProps): JSX.Element => {
);
};
export const LeftRail = (): JSX.Element => {
const personalItem = railItems.find((item) => item.kind === "personal");
const organizationItems = railItems.filter((item) => item.kind === "organization");
type LeftRailProps = {
collapsed: boolean;
};
export const LeftRail = (props: LeftRailProps): JSX.Element => {
const appShellData = useAppShellData();
const personalItem = () => appShellData.railItems().find((item) => item.kind === "personal");
const organizationItems = () => appShellData.railItems().filter((item) => item.kind === "organization");
return (
<aside class={styles.rail} aria-label="Server rail">
<aside
classList={{
[styles.rail]: true,
[styles.railCollapsed]: props.collapsed,
}}
aria-label="Server rail"
>
<div class={styles.topCluster}>
{personalItem ? <RailEntry item={personalItem} label={personalItem.label} abbreviation="M" personal /> : null}
<Show when={!props.collapsed && personalItem()}>
{(item): JSX.Element => (
<RailEntry item={item()} label={item().label} abbreviation={item().abbreviation} personal />
)}
</Show>
<div class={styles.sectionDivider} aria-hidden="true" />
<Show when={!props.collapsed}>
<div class={styles.sectionDivider} aria-hidden="true" />
</Show>
</div>
<div class={styles.items}>
<For each={organizationItems}>
{(item): JSX.Element => <RailEntry item={item} label={item.label} abbreviation={item.abbreviation} />}
</For>
</div>
<div class={styles.bottomCluster}>
<button type="button" class={styles.addButton} aria-label="Create server" title="Create server">
<Plus size={16} strokeWidth={2} />
</button>
</div>
<Show when={!props.collapsed}>
<div class={styles.items}>
<For each={organizationItems()}>
{(item): JSX.Element => <RailEntry item={item} label={item.label} abbreviation={item.abbreviation} />}
</For>
</div>
</Show>
</aside>
);
};

View File

@@ -0,0 +1,108 @@
.mobileNav {
display: none;
}
@include respond-down(mobile) {
.mobileNav {
--mobile-nav-button-gap: var(--space-1);
--mobile-nav-button-padding-inline: var(--space-2);
--mobile-nav-button-padding-top: calc(var(--space-2) + (var(--space-1) / 2));
--mobile-nav-button-padding-bottom: var(--space-2);
position: fixed;
right: 0;
bottom: 0;
left: 0;
display: grid;
gap: var(--space-2);
padding: var(--space-2) var(--space-3) calc(var(--space-2) + env(safe-area-inset-bottom, 0px));
background:
linear-gradient(to top, color-mix(in srgb, var(--color-canvas) 98%, transparent), color-mix(in srgb, var(--color-canvas) 92%, transparent));
border-top: 1px solid color-mix(in srgb, var(--color-border-strong) 40%, transparent);
backdrop-filter: blur(var(--blur-overlay));
z-index: var(--z-sticky);
}
.contextBar {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-width: 0;
color: var(--color-text-muted);
}
.contextServer,
.contextProject {
@include text-caption;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contextServer {
max-width: 45vw;
}
.contextProject {
max-width: 30vw;
}
.contextDivider {
@include text-caption;
color: var(--color-text-subtle);
}
.navGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-2);
}
.navButton {
min-width: 0;
display: grid;
justify-items: center;
gap: var(--mobile-nav-button-gap);
padding: var(--mobile-nav-button-padding-top) var(--mobile-nav-button-padding-inline) var(--mobile-nav-button-padding-bottom);
border: 1px solid transparent;
border-radius: var(--radius-xl);
background: transparent;
color: var(--color-text-muted);
transition:
background 160ms var(--easing-standard),
color 160ms var(--easing-standard),
border-color 160ms var(--easing-standard),
transform 180ms var(--easing-standard);
}
.navButton:hover,
.navButton:focus-visible {
color: var(--color-text);
background: color-mix(in srgb, var(--color-surface) 82%, transparent);
border-color: color-mix(in srgb, var(--color-border-strong) 28%, transparent);
}
.navButton:active {
transform: translateY(calc(var(--space-1) / 2));
}
.navButtonActive {
color: var(--color-text);
background: color-mix(in srgb, var(--color-surface) 90%, transparent);
border-color: color-mix(in srgb, var(--color-border-strong) 34%, transparent);
box-shadow: var(--shadow-soft);
}
.iconWrap {
display: inline-flex;
align-items: center;
justify-content: center;
}
.label {
@include text-caption;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}

View File

@@ -0,0 +1,66 @@
// Path: Frontend/src/components/shell/MobileBottomNav/MobileBottomNav.tsx
import { For, type JSX } from "solid-js";
import { useAppShellData } from "../data/app-shell.context";
import { mobileBottomNavItems, type MobileBottomNavItem } from "../data/shell.data";
import styles from "./MobileBottomNav.module.scss";
type MobileBottomNavProps = {
isBrowseOpen: boolean;
onBrowseToggle: VoidFunction;
};
const MobileNavEntry = (props: {
item: MobileBottomNavItem;
isActive: boolean;
onSelect?: VoidFunction;
}): JSX.Element => {
const Icon = props.item.icon;
return (
<button
type="button"
onClick={() => props.onSelect?.()}
classList={{
[styles.navButton]: true,
[styles.navButtonActive]: props.isActive,
}}
aria-current={props.isActive ? "page" : undefined}
aria-expanded={props.item.id === "browse" ? props.isActive : undefined}
aria-label={props.item.label}
title={props.item.label}
>
<span class={styles.iconWrap} aria-hidden="true">
<Icon size={18} strokeWidth={2} />
</span>
<span class={styles.label}>{props.item.label}</span>
</button>
);
};
export const MobileBottomNav = (props: MobileBottomNavProps): JSX.Element => {
const appShellData = useAppShellData();
return (
<nav class={styles.mobileNav} aria-label="Mobile workspace navigation">
<div class={styles.contextBar}>
<span class={styles.contextServer}>{appShellData.activeServer().name}</span>
<span class={styles.contextDivider}>/</span>
<span class={styles.contextProject}>{appShellData.activeProject().name}</span>
</div>
<div class={styles.navGrid}>
<For each={mobileBottomNavItems}>
{(item): JSX.Element => (
<MobileNavEntry
item={item}
isActive={item.id === "browse" ? props.isBrowseOpen : (item.active ?? false) && !props.isBrowseOpen}
onSelect={item.id === "browse" ? props.onBrowseToggle : undefined}
/>
)}
</For>
</div>
</nav>
);
};

View File

@@ -0,0 +1,190 @@
.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));
}
}

View File

@@ -0,0 +1,247 @@
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>
);
};

View File

@@ -2,7 +2,11 @@
display: grid;
--project-drawer-gap: var(--space-3);
--project-drawer-top: calc(var(--space-4) + var(--control-size-lg));
--project-drawer-bottom: calc(var(--sidebar-dock-clearance) + var(--project-drawer-gap));
--project-drawer-bottom: calc(var(--sidebar-dock-clearance, var(--shell-dock-clearance)) + var(--project-drawer-gap));
}
.rootCompact {
justify-items: center;
}
.trigger {
@@ -39,6 +43,29 @@
box-shadow: var(--shadow-soft);
}
.triggerCompact {
width: var(--control-size-xl);
min-height: var(--control-size-xl);
grid-template-columns: auto;
justify-items: center;
gap: 0.15rem;
padding: var(--space-2) 0;
border-radius: var(--radius-xl);
}
.triggerCompact .triggerLead {
width: var(--control-size-md);
height: var(--control-size-md);
}
.triggerCompact .triggerIcon {
transform: rotate(0deg);
}
.triggerCompact .triggerIconOpen {
transform: rotate(180deg);
}
.triggerLead {
width: var(--control-size-md);
height: var(--control-size-md);
@@ -99,6 +126,13 @@
pointer-events: auto;
}
.rootCompact .scrim,
.rootCompact .drawer {
left: 0;
right: auto;
width: min(18rem, calc(100vw - 6rem));
}
.drawer {
position: absolute;
inset: calc(var(--project-drawer-top) + var(--project-drawer-gap)) var(--space-4)

View File

@@ -1,23 +1,27 @@
// Path: Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx
import { For, createSignal, onCleanup, onMount, type JSX } from "solid-js";
import { For, createEffect, createSignal, onCleanup, onMount, type JSX } from "solid-js";
import { ChevronDown, Folder } from "../../../lib/icons";
import { activeProject, projectItems } from "../data/shell.data";
import { useAppShellData } from "../data/app-shell.context";
import styles from "./ProjectSelector.module.scss";
type ProjectSelectorProps = {
compact?: boolean;
isOpen: boolean;
onToggle: () => void;
onClose: () => void;
};
const defaultProject = projectItems.find((item) => item.id === activeProject.id) ?? projectItems[0];
export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
const [selectedProject, setSelectedProject] = createSignal({ id: defaultProject.id, name: defaultProject.name });
const appShellData = useAppShellData();
const [selectedProject, setSelectedProject] = createSignal(appShellData.activeProject());
const [drawerTop, setDrawerTop] = createSignal<number>(0);
let triggerRef: HTMLButtonElement | undefined;
createEffect(() => {
setSelectedProject(appShellData.activeProject());
});
onMount(() => {
if (!triggerRef) {
return;
@@ -56,7 +60,7 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
};
const selectProject = (projectId: string): void => {
const nextProject = projectItems.find((item): boolean => item.id === projectId);
const nextProject = appShellData.projectItems().find((item): boolean => item.id === projectId);
if (!nextProject) {
return;
@@ -68,7 +72,10 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
return (
<div
class={styles.root}
classList={{
[styles.root]: true,
[styles.rootCompact]: !!props.compact,
}}
style={{
"--project-drawer-top": `${drawerTop()}px`,
}}
@@ -79,20 +86,23 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
ref={triggerRef}
classList={{
[styles.trigger]: true,
[styles.triggerCompact]: !!props.compact,
[styles.triggerOpen]: props.isOpen,
}}
aria-label="Open project drawer"
aria-label={`Open left workspace sidebar menu for ${selectedProject().name}`}
aria-expanded={props.isOpen}
title="Open project drawer"
title={selectedProject().name}
onClick={toggleOpen}
>
<span class={styles.triggerLead} aria-hidden="true">
<Folder size={18} strokeWidth={2} />
</span>
<span class={styles.triggerCopy}>
<span class={styles.eyebrow}>Projects</span>
<span class={styles.value}>{selectedProject().name}</span>
</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,
@@ -125,7 +135,7 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
>
<div class={styles.drawerBody}>
<ul class={styles.projectList} role="list">
<For each={projectItems}>
<For each={appShellData.projectItems()}>
{(item): JSX.Element => {
const isSelected = (): boolean => selectedProject().id === item.id;

View File

@@ -1,38 +1,41 @@
// Path: Frontend/src/components/shell/ServerDock/ServerDock.tsx
import { For, Show, type JSX } from "solid-js";
import { activeServer } from "../data/shell.data";
import { useAppShellData } from "../data/app-shell.context";
import styles from "./ServerDock.module.scss";
export const ServerDock = (): JSX.Element => {
const appShellData = useAppShellData();
const activeServer = () => appShellData.activeServer();
return (
<section class={styles.panel} aria-label="Server dock">
<div class={styles.identity}>
<section class={styles.panel} aria-label="Server dock" data-ui="server-dock" data-server-kind={activeServer().kind}>
<div class={styles.identity} data-slot="server-dock-identity">
<div class={styles.glyph} aria-hidden="true">
{activeServer.abbreviation}
{activeServer().abbreviation}
</div>
<div class={styles.copy}>
<span class={styles.name}>{activeServer.name}</span>
<div class={styles.copy} data-slot="server-dock-copy">
<span class={styles.name}>{activeServer().name}</span>
<Show
when={activeServer.kind === "organization"}
fallback={<span class={styles.subtitle}>{activeServer.subtitle}</span>}
when={activeServer().kind === "organization"}
fallback={<span class={styles.subtitle}>{activeServer().subtitle}</span>}
>
<span class={styles.status}>
<span class={styles.statusDot} aria-hidden="true" />
<span>{activeServer.connectedLabel}</span>
<span>{activeServer().connectedLabel}</span>
</span>
</Show>
</div>
</div>
<Show when={activeServer.dockActions.length > 0}>
<div class={styles.actions}>
<For each={activeServer.dockActions}>
<Show when={activeServer().dockActions.length > 0}>
<div class={styles.actions} data-slot="server-dock-actions">
<For each={activeServer().dockActions}>
{(item): JSX.Element => {
const Icon = item.icon;
return (
<button type="button" class={styles.action} aria-label={item.label} title={item.label}>
<button type="button" class={styles.action} aria-label={item.label} title={item.label} data-slot="server-dock-action" data-action-id={item.id}>
<Icon size={16} strokeWidth={2} />
<span class={styles.actionLabel}>{item.label}</span>
</button>

View File

@@ -7,11 +7,34 @@
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--color-border-strong);
border-radius: calc(var(--radius-lg) + 0.1rem);
border-radius: calc(var(--radius-lg) + (var(--space-1) / 2));
background: color-mix(in srgb, var(--color-surface-muted) 96%, transparent);
box-shadow: 0 18px 36px color-mix(in srgb, black 16%, transparent);
backdrop-filter: blur(18px);
z-index: 30;
box-shadow: var(--shadow-strong);
backdrop-filter: blur(var(--blur-overlay));
z-index: var(--z-dropdown);
}
.menu.menuWorkspace {
position: static;
top: auto;
right: auto;
left: auto;
bottom: auto;
width: 100%;
max-width: none;
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: var(--space-4);
padding: var(--space-4);
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
backdrop-filter: none;
z-index: auto;
overflow: hidden;
}
.header,
@@ -30,7 +53,7 @@
.headerCopy {
min-width: 0;
display: grid;
gap: 0.08rem;
gap: calc(var(--space-1) / 2);
}
.title {
@@ -125,7 +148,7 @@
.list {
display: grid;
gap: 0.3rem;
gap: var(--space-1);
}
.item {
@@ -181,7 +204,7 @@
.itemBody {
min-width: 0;
display: grid;
gap: 0.12rem;
gap: calc(var(--space-1) / 2);
}
.itemTitle {
@@ -190,7 +213,7 @@
}
.itemTime {
padding-top: 0.05rem;
padding-top: calc(var(--space-1) / 4);
white-space: nowrap;
color: var(--color-text-subtle);
}
@@ -202,26 +225,47 @@
flex-wrap: wrap;
}
.menu.menuWorkspace .listWrap {
min-height: 0;
max-height: none;
height: 100%;
padding-right: 0;
margin-right: 0;
}
.menu.menuWorkspace .header,
.menu.menuWorkspace .footer {
padding-left: 0;
padding-right: 0;
}
@include respond-down(mobile) {
.menu {
width: min(22rem, calc(100vw - (var(--space-3) * 2)));
.menu.menuWorkspace {
height: 100%;
min-height: 0;
padding: var(--space-5);
}
.item {
.menu.menuWorkspace .listWrap {
min-height: 0;
max-height: none;
}
.menu.menuWorkspace .item {
grid-template-columns: auto minmax(0, 1fr);
}
.itemTime {
.menu.menuWorkspace .itemTime {
grid-column: 2;
padding-top: 0;
}
.footer {
.menu.menuWorkspace .footer {
align-items: flex-start;
flex-direction: column;
}
.footerAction {
.menu.menuWorkspace .footerAction {
padding: var(--space-1) 0;
}
}
}

View File

@@ -5,8 +5,9 @@ import styles from "./NotificationsMenu.module.scss";
type NotificationsMenuProps = {
id: string;
menuRef: (element: HTMLDivElement) => void;
menuRef?: (element: HTMLDivElement) => void;
onSelect: () => void;
variant?: "popover" | "workspace";
};
export const NotificationsMenu = (props: NotificationsMenuProps): JSX.Element => {
@@ -14,11 +15,23 @@ export const NotificationsMenu = (props: NotificationsMenuProps): JSX.Element =>
const earlierItems = notificationItems.filter((item) => !item.unread);
const hasNotifications = notificationItems.length > 0;
const isCaughtUp = unreadItems.length === 0 && hasNotifications;
const variant = props.variant ?? "popover";
return (
<div id={props.id} class={styles.menu} role="menu" aria-label="Notifications" ref={props.menuRef}>
<div class={styles.header}>
<div class={styles.headerCopy}>
<div
id={props.id}
classList={{
[styles.menu]: true,
[styles.menuWorkspace]: variant === "workspace",
}}
role="menu"
aria-label="Notifications"
ref={props.menuRef}
data-ui="notifications-menu"
data-variant={variant}
>
<div class={styles.header} data-slot="notifications-header">
<div class={styles.headerCopy} data-slot="notifications-header-copy">
<strong class={styles.title}>Notifications</strong>
<span class={styles.subtitle}>
{unreadNotificationCount > 0
@@ -34,7 +47,7 @@ export const NotificationsMenu = (props: NotificationsMenuProps): JSX.Element =>
</Show>
</div>
<div class={styles.listWrap}>
<div class={styles.listWrap} data-slot="notifications-body">
<Show when={!hasNotifications}>
<div class={styles.stateCard}>
<span class={styles.stateIcon} aria-hidden="true">
@@ -56,12 +69,12 @@ export const NotificationsMenu = (props: NotificationsMenuProps): JSX.Element =>
</Show>
<Show when={unreadItems.length > 0}>
<section class={styles.section} aria-label="Unread notifications">
<section class={styles.section} aria-label="Unread notifications" data-slot="notifications-section" data-section-id="unread">
<span class={styles.sectionLabel}>Unread</span>
<div class={styles.list}>
<div class={styles.list} data-slot="notifications-list" data-section-id="unread">
<For each={unreadItems}>
{(item): JSX.Element => (
<button type="button" role="menuitem" classList={{ [styles.item]: true, [styles.itemUnread]: true }} onClick={props.onSelect}>
<button type="button" role="menuitem" classList={{ [styles.item]: true, [styles.itemUnread]: true }} data-slot="notification-item" data-state="unread" onClick={props.onSelect}>
<span class={styles.itemMarker} aria-hidden="true" />
<div class={styles.itemBody}>
<span class={styles.itemTitle}>{item.title}</span>
@@ -76,12 +89,12 @@ export const NotificationsMenu = (props: NotificationsMenuProps): JSX.Element =>
</Show>
<Show when={earlierItems.length > 0}>
<section class={styles.section} aria-label="Earlier notifications">
<section class={styles.section} aria-label="Earlier notifications" data-slot="notifications-section" data-section-id="earlier">
<span class={styles.sectionLabel}>Earlier</span>
<div class={styles.list}>
<div class={styles.list} data-slot="notifications-list" data-section-id="earlier">
<For each={earlierItems}>
{(item): JSX.Element => (
<button type="button" role="menuitem" class={styles.item} onClick={props.onSelect}>
<button type="button" role="menuitem" class={styles.item} data-slot="notification-item" data-state="read" onClick={props.onSelect}>
<span class={styles.itemMarkerMuted} aria-hidden="true" />
<div class={styles.itemBody}>
<span class={styles.itemTitle}>{item.title}</span>
@@ -96,7 +109,7 @@ export const NotificationsMenu = (props: NotificationsMenuProps): JSX.Element =>
</Show>
</div>
<div class={styles.footer}>
<div class={styles.footer} data-slot="notifications-footer">
<button type="button" role="menuitem" class={styles.footerAction} onClick={props.onSelect}>
<Settings size={16} strokeWidth={2} />
<span>Notification settings</span>

View File

@@ -1,56 +1,38 @@
import { createEffect, createSignal, createUniqueId, onCleanup, type JSX } from "solid-js";
import { createUniqueId, Show, type JSX } from "solid-js";
import { NotificationsButton } from "./NotificationsButton";
import { NotificationsMenu } from "./NotificationsMenu";
import { createDesktopMenuController } from "./createDesktopMenuController";
import styles from "./NotificationsNav.module.scss";
export const NotificationsNav = (): JSX.Element => {
const [isOpen, setIsOpen] = createSignal(false);
type NotificationsNavProps = {
isMobileViewport: boolean;
isMobileWorkspaceOpen: boolean;
onToggleMobileWorkspace: VoidFunction;
};
export const NotificationsNav = (props: NotificationsNavProps): JSX.Element => {
const menuId = createUniqueId();
let rootRef: HTMLDivElement | undefined;
let menuRef: HTMLDivElement | undefined;
const closeMenu = (): void => {
setIsOpen(false);
};
const toggleMenu = (): void => {
setIsOpen((open) => !open);
};
createEffect(() => {
if (!isOpen()) return;
const handlePointerDown = (event: PointerEvent): void => {
if (!rootRef) return;
const target = event.target;
if (target instanceof Node && !rootRef.contains(target)) {
closeMenu();
}
};
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key === "Escape") {
closeMenu();
}
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
menuRef?.querySelector<HTMLButtonElement>("[role='menuitem']")?.focus();
onCleanup(() => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
});
});
return (
<div class={styles.root} ref={rootRef}>
<NotificationsButton isOpen={isOpen()} menuId={menuId} onToggle={toggleMenu} />
{isOpen() ? (
<NotificationsMenu id={menuId} menuRef={(element) => (menuRef = element)} onSelect={closeMenu} />
) : null}
<Show
when={props.isMobileViewport}
fallback={<DesktopNotificationsNav />}
>
<div class={styles.root}>
<NotificationsButton isOpen={props.isMobileWorkspaceOpen} menuId={menuId} onToggle={props.onToggleMobileWorkspace} />
</div>
</Show>
);
};
const DesktopNotificationsNav = (): JSX.Element => {
const controller = createDesktopMenuController();
const menuId = createUniqueId();
return (
<div class={styles.root} ref={controller.setRootRef}>
<NotificationsButton isOpen={controller.isOpen()} menuId={menuId} onToggle={controller.toggleMenu} />
{controller.isOpen() ? <NotificationsMenu id={menuId} menuRef={controller.setMenuRef} onSelect={controller.closeMenu} /> : null}
</div>
);
};

View File

@@ -7,11 +7,39 @@
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--color-border-strong);
border-radius: calc(var(--radius-lg) + 0.1rem);
border-radius: calc(var(--radius-lg) + (var(--space-1) / 2));
background: color-mix(in srgb, var(--color-surface-muted) 96%, transparent);
box-shadow: 0 18px 36px color-mix(in srgb, black 16%, transparent);
backdrop-filter: blur(18px);
z-index: 30;
box-shadow: var(--shadow-strong);
backdrop-filter: blur(var(--blur-overlay));
z-index: var(--z-dropdown);
}
.menu.menuWorkspace {
position: static;
top: auto;
right: auto;
left: auto;
bottom: auto;
width: 100%;
max-width: none;
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: var(--space-4);
padding: var(--space-4);
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
backdrop-filter: none;
z-index: auto;
overflow: hidden;
}
.sections {
display: grid;
gap: var(--space-3);
}
.summary {
@@ -71,7 +99,7 @@
.summaryCopy {
min-width: 0;
display: grid;
gap: 0.08rem;
gap: calc(var(--space-1) / 2);
}
.name,
@@ -97,7 +125,7 @@
.section {
display: grid;
gap: 0.2rem;
gap: var(--space-1);
}
.section + .section {
@@ -108,7 +136,7 @@
.item {
width: 100%;
min-width: 0;
min-height: 2.65rem;
min-height: calc(var(--control-size-lg) + (var(--space-1) / 2));
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
@@ -148,8 +176,8 @@
}
.itemIcon {
width: 1.9rem;
height: 1.9rem;
width: calc(var(--control-size-lg) - var(--space-2));
height: calc(var(--control-size-lg) - var(--space-2));
display: inline-flex;
align-items: center;
justify-content: center;
@@ -158,8 +186,15 @@
color: currentColor;
}
@include respond-down(mobile) {
.menu {
width: min(20rem, calc(100vw - (var(--space-3) * 2)));
}
.menu.menuWorkspace .sections {
min-height: 0;
height: 100%;
align-content: start;
overflow-y: auto;
}
.menu.menuWorkspace .summary,
.menu.menuWorkspace .sections {
padding-left: 0;
padding-right: 0;
}

View File

@@ -1,18 +1,35 @@
import { For, type JSX } from "solid-js";
import { User } from "../../../lib/icons";
import { activeUserProfile, profileMenuSections } from "../data/shell.data";
import { useAppShellData } from "../data/app-shell.context";
import { profileMenuSections } from "../data/shell.data";
import styles from "./ProfileMenu.module.scss";
type ProfileMenuProps = {
id: string;
menuRef: (element: HTMLDivElement) => void;
menuRef?: (element: HTMLDivElement) => void;
onSelect: () => void;
variant?: "popover" | "workspace";
};
export const ProfileMenu = (props: ProfileMenuProps): JSX.Element => {
const variant = props.variant ?? "popover";
const appShellData = useAppShellData();
const activeUserProfile = () => appShellData.activeUserProfile();
return (
<div id={props.id} class={styles.menu} role="menu" aria-label="Profile menu" ref={props.menuRef}>
<div class={styles.summary}>
<div
id={props.id}
classList={{
[styles.menu]: true,
[styles.menuWorkspace]: variant === "workspace",
}}
role="menu"
aria-label="Profile menu"
ref={props.menuRef}
data-ui="profile-menu"
data-variant={variant}
>
<div class={styles.summary} data-slot="profile-summary">
<div class={styles.avatar} aria-hidden="true">
<span class={styles.avatarRing} />
<span class={styles.avatarCore}>
@@ -21,41 +38,46 @@ export const ProfileMenu = (props: ProfileMenuProps): JSX.Element => {
</div>
<div class={styles.summaryCopy}>
<strong class={styles.name}>{activeUserProfile.name}</strong>
<span class={styles.email}>{activeUserProfile.email}</span>
<span class={styles.role}>{activeUserProfile.roleLabel}</span>
<span class={styles.context}>{activeUserProfile.contextLabel}</span>
<strong class={styles.name}>{activeUserProfile().name}</strong>
<span class={styles.email}>{activeUserProfile().email}</span>
<span class={styles.role}>{activeUserProfile().roleLabel}</span>
<span class={styles.context}>{activeUserProfile().contextLabel}</span>
</div>
</div>
<For each={profileMenuSections}>
{(section): JSX.Element => (
<div class={styles.section}>
<For each={section.items}>
{(item): JSX.Element => {
const Icon = item.icon;
<div class={styles.sections} data-slot="profile-sections">
<For each={profileMenuSections}>
{(section): JSX.Element => (
<div class={styles.section} data-slot="profile-section" data-section-id={section.id}>
<For each={section.items}>
{(item): JSX.Element => {
const Icon = item.icon;
return (
<button
type="button"
role="menuitem"
return (
<button
type="button"
role="menuitem"
classList={{
[styles.item]: true,
[styles.itemDanger]: item.tone === "danger",
}}
data-slot="profile-action"
data-action-id={item.id}
data-tone={item.tone ?? "default"}
onClick={props.onSelect}
>
<span class={styles.itemIcon} aria-hidden="true">
<Icon size={16} strokeWidth={2} />
</span>
<span class={styles.itemLabel}>{item.label}</span>
</button>
);
}}
</For>
</div>
)}
</For>
>
<span class={styles.itemIcon} aria-hidden="true">
<Icon size={16} strokeWidth={2} />
</span>
<span class={styles.itemLabel}>{item.label}</span>
</button>
);
}}
</For>
</div>
)}
</For>
</div>
</div>
);
};

View File

@@ -12,24 +12,29 @@ import styles from "./TopBar.module.scss";
type TopBarProps = {
theme: Theme;
onToggleTheme: VoidFunction;
isMobileViewport: boolean;
isNotificationsOpen: boolean;
isProfileOpen: boolean;
onToggleNotifications: VoidFunction;
onToggleProfile: VoidFunction;
};
export const TopBar = (props: TopBarProps): JSX.Element => {
return (
<header class={styles.topBar}>
<div class={styles.identity}>
<header class={styles.topBar} data-ui="top-bar">
<div class={styles.identity} data-slot="top-bar-identity">
<span class={styles.eyebrow}>Moku Work</span>
<DepartmentSelector />
</div>
<div class={styles.controls}>
<div class={styles.actions}>
<div class={styles.controls} data-slot="top-bar-controls">
<div class={styles.actions} data-slot="top-bar-actions">
<For each={topBarActions}>
{(item): JSX.Element => {
const Icon = item.icon;
return (
<button class={styles.actionButton} type="button" aria-label={item.label} title={item.label}>
<button class={styles.actionButton} type="button" aria-label={item.label} title={item.label} data-slot="top-bar-action" data-action-id={item.id}>
<Icon size={18} strokeWidth={2} />
</button>
);
@@ -37,9 +42,17 @@ export const TopBar = (props: TopBarProps): JSX.Element => {
</For>
</div>
<NotificationsNav />
<NotificationsNav
isMobileViewport={props.isMobileViewport}
isMobileWorkspaceOpen={props.isNotificationsOpen}
onToggleMobileWorkspace={props.onToggleNotifications}
/>
<ThemeToggle theme={props.theme} onToggle={props.onToggleTheme} />
<UserNav />
<UserNav
isMobileViewport={props.isMobileViewport}
isMobileWorkspaceOpen={props.isProfileOpen}
onToggleMobileWorkspace={props.onToggleProfile}
/>
</div>
</header>
);

View File

@@ -1,54 +1,38 @@
import { createEffect, createSignal, createUniqueId, onCleanup, type JSX } from "solid-js";
import { createUniqueId, Show, type JSX } from "solid-js";
import { ProfileMenu } from "./ProfileMenu";
import { UserNavButton } from "./UserNavButton";
import { createDesktopMenuController } from "./createDesktopMenuController";
import styles from "./UserNav.module.scss";
export const UserNav = (): JSX.Element => {
const [isOpen, setIsOpen] = createSignal(false);
type UserNavProps = {
isMobileViewport: boolean;
isMobileWorkspaceOpen: boolean;
onToggleMobileWorkspace: VoidFunction;
};
export const UserNav = (props: UserNavProps): JSX.Element => {
const menuId = createUniqueId();
let rootRef: HTMLDivElement | undefined;
let menuRef: HTMLDivElement | undefined;
const closeMenu = (): void => {
setIsOpen(false);
};
const toggleMenu = (): void => {
setIsOpen((open) => !open);
};
createEffect(() => {
if (!isOpen()) return;
const handlePointerDown = (event: PointerEvent): void => {
if (!rootRef) return;
const target = event.target;
if (target instanceof Node && !rootRef.contains(target)) {
closeMenu();
}
};
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key === "Escape") {
closeMenu();
}
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
menuRef?.querySelector<HTMLButtonElement>("[role='menuitem']")?.focus();
onCleanup(() => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
});
});
return (
<div class={styles.root} ref={rootRef}>
<UserNavButton isOpen={isOpen()} menuId={menuId} onToggle={toggleMenu} />
{isOpen() ? <ProfileMenu id={menuId} menuRef={(element) => (menuRef = element)} onSelect={closeMenu} /> : null}
<Show
when={props.isMobileViewport}
fallback={<DesktopUserNav />}
>
<div class={styles.root}>
<UserNavButton isOpen={props.isMobileWorkspaceOpen} menuId={menuId} onToggle={props.onToggleMobileWorkspace} />
</div>
</Show>
);
};
const DesktopUserNav = (): JSX.Element => {
const controller = createDesktopMenuController();
const menuId = createUniqueId();
return (
<div class={styles.root} ref={controller.setRootRef}>
<UserNavButton isOpen={controller.isOpen()} menuId={menuId} onToggle={controller.toggleMenu} />
{controller.isOpen() ? <ProfileMenu id={menuId} menuRef={controller.setMenuRef} onSelect={controller.closeMenu} /> : null}
</div>
);
};

View File

@@ -0,0 +1,72 @@
import { createEffect, createSignal, onCleanup } from "solid-js";
type DesktopMenuController = {
isOpen: () => boolean;
rootRef: HTMLDivElement | undefined;
menuRef: HTMLDivElement | undefined;
setRootRef: (element: HTMLDivElement) => void;
setMenuRef: (element: HTMLDivElement) => void;
closeMenu: VoidFunction;
toggleMenu: VoidFunction;
};
// Shared desktop popover behavior for top-bar menus.
export const createDesktopMenuController = (): DesktopMenuController => {
const [isOpen, setIsOpen] = createSignal(false);
let rootRef: HTMLDivElement | undefined;
let menuRef: HTMLDivElement | undefined;
const closeMenu = (): void => {
setIsOpen(false);
};
const toggleMenu = (): void => {
setIsOpen((open) => !open);
};
createEffect(() => {
if (!isOpen()) return;
const handlePointerDown = (event: PointerEvent): void => {
if (!rootRef) return;
const target = event.target;
if (target instanceof Node && !rootRef.contains(target)) {
closeMenu();
}
};
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key === "Escape") {
closeMenu();
}
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
menuRef?.querySelector<HTMLButtonElement>("[role='menuitem']")?.focus();
onCleanup(() => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
});
});
return {
isOpen,
get rootRef() {
return rootRef;
},
get menuRef() {
return menuRef;
},
setRootRef: (element: HTMLDivElement): void => {
rootRef = element;
},
setMenuRef: (element: HTMLDivElement): void => {
menuRef = element;
},
closeMenu,
toggleMenu,
};
};

View File

@@ -0,0 +1,194 @@
.menu {
--context-menu-width: 13.5rem;
position: fixed;
width: min(var(--context-menu-width), calc(100vw - (var(--space-4) * 2)));
display: grid;
gap: 0;
padding: var(--space-1);
border: 1px solid color-mix(in srgb, var(--color-border-strong) 56%, transparent);
border-radius: calc(var(--radius-md) + (var(--space-1) / 2));
background: var(--color-surface);
box-shadow: var(--shadow-soft);
z-index: 2147483647;
user-select: none;
}
.header {
display: grid;
gap: calc(var(--space-1) / 2);
padding: var(--space-2) calc(var(--space-2) + (var(--space-1) / 2));
}
.eyebrow {
@include text-caption;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.title {
@include text-label;
color: var(--color-text);
font-weight: var(--font-weight-title);
}
.sectionList {
display: grid;
gap: 0;
}
.sectionListCompact {
gap: calc(var(--space-1) / 2);
}
.section {
display: grid;
gap: 0;
}
.section:first-child {
padding-top: calc(var(--space-1) / 2);
}
.section + .section {
margin-top: calc(var(--space-1) / 2);
padding-top: var(--space-2);
border-top: 1px solid color-mix(in srgb, var(--color-border) 78%, transparent);
}
.sectionLabel {
@include text-caption;
color: var(--color-text-subtle);
padding: 0 var(--space-2);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.actionList {
display: grid;
gap: 0;
}
.actionItem {
position: relative;
}
.action {
width: 100%;
min-height: calc(var(--control-size-md) - var(--space-2));
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: var(--space-2);
padding: var(--space-2) calc(var(--space-2) + (var(--space-1) / 2));
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
text-align: left;
font-size: var(--font-size-label);
line-height: var(--line-height-label);
font-weight: var(--font-weight-label);
transition:
background var(--motion-duration-fast) var(--motion-ease-standard),
border-color var(--motion-duration-fast) var(--motion-ease-standard),
color var(--motion-duration-fast) var(--motion-ease-standard);
}
.actionCreate {
border-color: transparent;
background: transparent;
font-weight: var(--font-weight-title);
box-shadow: none;
}
.actionCreate:hover,
.actionCreate:focus-visible,
.actionCreate.actionSubmenuOpen {
background: color-mix(in srgb, var(--color-surface-hover) 82%, var(--color-surface));
border-color: color-mix(in srgb, var(--color-border) 72%, transparent);
}
.actionCreateIcon {
width: calc(var(--control-size-md) - var(--space-3));
height: calc(var(--control-size-md) - var(--space-3));
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-surface-hover) 72%, var(--color-surface));
color: var(--color-text);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-border) 74%, transparent);
}
.actionLabel {
min-width: 0;
}
.actionMeta {
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-2);
padding-left: var(--space-3);
}
.actionShortcut {
color: var(--color-text-muted);
font-size: var(--font-size-caption);
line-height: var(--line-height-caption);
font-weight: var(--font-weight-caption);
white-space: nowrap;
}
.actionChevron {
color: var(--color-text-subtle);
flex: 0 0 auto;
}
.actionSubmenuOpen {
background: color-mix(in srgb, var(--color-surface-hover) 82%, var(--color-surface));
border-color: color-mix(in srgb, var(--color-border-strong) 72%, transparent);
}
.action:hover,
.action:focus-visible {
background: color-mix(in srgb, var(--color-surface-hover) 78%, var(--color-surface));
border-color: color-mix(in srgb, var(--color-border) 72%, transparent);
}
.actionDanger {
color: var(--color-danger-text, var(--color-text));
}
.actionDanger:hover,
.actionDanger:focus-visible {
background: color-mix(in srgb, var(--color-danger-soft, var(--color-surface-hover)) 72%, transparent);
border-color: color-mix(in srgb, var(--color-danger-border, var(--color-border)) 72%, transparent);
color: var(--color-danger-text, var(--color-text));
}
.submenu {
position: absolute;
top: calc(var(--space-1) * -1);
left: calc(100% + var(--space-2));
width: min(var(--context-menu-width), calc(100vw - (var(--space-4) * 2)));
padding: var(--space-1);
border: 1px solid color-mix(in srgb, var(--color-border-strong) 56%, transparent);
border-radius: calc(var(--radius-md) + (var(--space-1) / 2));
background: var(--color-surface);
box-shadow: var(--shadow-soft);
z-index: 2147483647;
}
.submenuList {
display: grid;
gap: var(--space-1);
}
@include respond-down(mobile) {
.menu {
display: none;
}
}

View File

@@ -0,0 +1,242 @@
import { For, Show, createEffect, createMemo, createSignal, onMount, type JSX } from "solid-js";
import { Portal } from "solid-js/web";
import { ChevronRight, Plus } from "../../../lib/icons";
import {
getWorkspaceContextMenuEyebrow,
getWorkspaceContextMenuSections,
type WorkspaceContextMenuAction,
type WorkspaceContextMenuShortcut,
type WorkspaceContextMenuTarget,
} from "../data/shell.data";
import styles from "./WorkspaceContextMenu.module.scss";
type ShortcutPlatform = "mac" | "windows";
type NavigatorWithUserAgentData = Navigator & {
userAgentData?: {
platform?: string;
};
};
type WorkspaceContextMenuPosition = {
x: number;
y: number;
};
type WorkspaceContextMenuProps = {
target: WorkspaceContextMenuTarget | null;
position: WorkspaceContextMenuPosition | null;
onClose: VoidFunction;
onSelect: (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget) => void;
menuRef: (element: HTMLDivElement) => void;
};
const getShortcutPlatform = (): ShortcutPlatform => {
if (typeof navigator === "undefined") {
return "mac";
}
const navigatorWithUserAgentData = navigator as NavigatorWithUserAgentData;
const platform =
typeof navigatorWithUserAgentData.userAgentData?.platform === "string"
? navigatorWithUserAgentData.userAgentData.platform
: navigator.platform;
return /mac|iphone|ipad|ipod/i.test(platform) ? "mac" : "windows";
};
const formatShortcut = (shortcut: WorkspaceContextMenuShortcut, platform: ShortcutPlatform): string => {
const keyLabel = (() => {
switch (shortcut.key) {
case "enter":
return platform === "mac" ? "↩" : "Enter";
case "delete":
return platform === "mac" ? "⌫" : "Del";
default:
return shortcut.key.toUpperCase();
}
})();
const modifierLabels =
shortcut.modifiers?.map((modifier) => {
switch (modifier) {
case "meta":
return platform === "mac" ? "⌘" : "Ctrl";
case "alt":
return platform === "mac" ? "⌥" : "Alt";
case "shift":
return platform === "mac" ? "⇧" : "Shift";
}
}) ?? [];
if (platform === "mac") {
return `${modifierLabels.join("")}${keyLabel}`;
}
return [...modifierLabels, keyLabel].join("+");
};
export const WorkspaceContextMenu = (props: WorkspaceContextMenuProps): JSX.Element => {
const [activeSubmenuActionId, setActiveSubmenuActionId] = createSignal<string | null>(null);
const [shortcutPlatform, setShortcutPlatform] = createSignal<ShortcutPlatform>("mac");
const sections = createMemo(() => (props.target ? getWorkspaceContextMenuSections(props.target) : []));
const handleActionSelect = (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget): void => {
props.onSelect(action, target);
props.onClose();
};
const menuState = createMemo<{
target: WorkspaceContextMenuTarget;
position: WorkspaceContextMenuPosition;
} | null>(() =>
props.target && props.position
? {
target: props.target,
position: props.position,
}
: null,
);
const showHeader = (): boolean => props.target?.kind !== "workspace";
const sectionHasLabel = createMemo(() => sections().some((section) => Boolean(section.label)));
onMount(() => {
setShortcutPlatform(getShortcutPlatform());
});
createEffect(() => {
void props.target;
setActiveSubmenuActionId(null);
});
return (
<Show when={menuState()}>
{(resolvedMenuState): JSX.Element => {
const target = resolvedMenuState().target;
const position = resolvedMenuState().position;
return (
<Portal>
<div
ref={props.menuRef}
class={styles.menu}
role="menu"
aria-label={`${target.label} context menu`}
data-ui="workspace-context-menu"
data-target-kind={target.kind}
data-item-type={target.kind === "item" ? target.itemType : undefined}
style={{
left: `${position.x}px`,
top: `${position.y}px`,
}}
>
<Show when={target.kind !== "workspace"}>
<header class={styles.header} data-slot="context-menu-header">
<span class={styles.eyebrow}>{getWorkspaceContextMenuEyebrow(target)}</span>
<strong class={styles.title}>{target.label}</strong>
</header>
</Show>
<div classList={{ [styles.sectionList]: true, [styles.sectionListCompact]: !sectionHasLabel() }} data-slot="context-menu-sections">
<For each={sections()}>
{(section): JSX.Element => (
<section class={styles.section} data-slot="context-menu-section" data-section-id={section.id}>
<Show when={section.label}>
<span class={styles.sectionLabel}>{section.label}</span>
</Show>
<div class={styles.actionList} data-slot="context-menu-action-list">
<For each={section.items}>
{(action): JSX.Element => {
const isSubmenuOpen = () => activeSubmenuActionId() === action.id;
return (
<div
class={styles.actionItem}
data-slot="context-menu-action-item"
data-action-id={action.id}
onMouseEnter={() => {
setActiveSubmenuActionId(action.children ? action.id : null);
}}
>
<button
type="button"
role="menuitem"
classList={{
[styles.action]: true,
[styles.actionCreate]: action.id === "create",
[styles.actionDanger]: action.tone === "danger",
[styles.actionSubmenuOpen]: isSubmenuOpen(),
}}
data-slot="context-menu-action"
data-action-id={action.id}
data-tone={action.tone ?? "default"}
onClick={() => {
if (action.children) {
setActiveSubmenuActionId(isSubmenuOpen() ? null : action.id);
return;
}
handleActionSelect(action, target);
}}
>
<Show when={action.id === "create"}>
<span class={styles.actionCreateIcon} aria-hidden="true">
<Plus size={14} strokeWidth={2.25} />
</span>
</Show>
<span class={styles.actionLabel}>{action.label}</span>
<div class={styles.actionMeta}>
<Show when={action.shortcut}>
<span class={styles.actionShortcut}>{formatShortcut(action.shortcut!, shortcutPlatform())}</span>
</Show>
<Show when={action.children}>
<ChevronRight class={styles.actionChevron} size={16} strokeWidth={2} />
</Show>
</div>
</button>
<Show when={action.children && isSubmenuOpen()}>
<div class={styles.submenu} role="menu" aria-label={`${action.label} submenu`} data-slot="context-menu-submenu">
<div class={styles.submenuList} data-slot="context-menu-submenu-list">
<For each={action.children ?? []}>
{(childAction): JSX.Element => (
<button
type="button"
role="menuitem"
classList={{
[styles.action]: true,
[styles.actionDanger]: childAction.tone === "danger",
}}
data-slot="context-menu-submenu-action"
data-action-id={childAction.id}
data-tone={childAction.tone ?? "default"}
onClick={() => handleActionSelect(childAction, target)}
>
<span class={styles.actionLabel}>{childAction.label}</span>
<div class={styles.actionMeta}>
<Show when={childAction.shortcut}>
<span class={styles.actionShortcut}>{formatShortcut(childAction.shortcut!, shortcutPlatform())}</span>
</Show>
</div>
</button>
)}
</For>
</div>
</div>
</Show>
</div>
);
}}
</For>
</div>
</section>
)}
</For>
</div>
</div>
</Portal>
);
}}
</Show>
);
};

View File

@@ -0,0 +1,134 @@
import { createEffect, createSignal, onCleanup } from "solid-js";
import type { WorkspaceContextMenuTarget } from "../data/shell.data";
type WorkspaceContextMenuState = {
target: WorkspaceContextMenuTarget;
x: number;
y: number;
};
const readRootPixelToken = (name: string, fallback: number): number => {
if (typeof window === "undefined") {
return fallback;
}
const value = window.getComputedStyle(document.documentElement).getPropertyValue(name).trim();
const parsed = Number.parseFloat(value);
if (!Number.isFinite(parsed)) {
return fallback;
}
if (value.endsWith("px")) {
return parsed;
}
return parsed * 16;
};
const clampMenuPosition = (value: number, min: number, max: number): number => {
if (max <= min) {
return min;
}
return Math.min(Math.max(value, min), max);
};
export const createWorkspaceContextMenuController = () => {
const [menuState, setMenuState] = createSignal<WorkspaceContextMenuState | null>(null);
let menuRef: HTMLDivElement | undefined;
const closeMenu = (): void => {
setMenuState(null);
};
const repositionMenu = (): void => {
if (typeof window === "undefined" || !menuRef) {
return;
}
const current = menuState();
if (!current) {
return;
}
const viewportPadding = readRootPixelToken("--space-4", 16);
const rect = menuRef.getBoundingClientRect();
const nextX = clampMenuPosition(current.x, viewportPadding, window.innerWidth - rect.width - viewportPadding);
const nextY = clampMenuPosition(current.y, viewportPadding, window.innerHeight - rect.height - viewportPadding);
if (nextX === current.x && nextY === current.y) {
return;
}
setMenuState({ ...current, x: nextX, y: nextY });
};
const openMenu = (event: MouseEvent, target: WorkspaceContextMenuTarget): void => {
event.preventDefault();
setMenuState({ target, x: event.clientX, y: event.clientY });
};
const openMenuFromElement = (element: HTMLElement, target: WorkspaceContextMenuTarget): void => {
const rect = element.getBoundingClientRect();
setMenuState({
target,
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
});
};
createEffect(() => {
if (!menuState()) {
return;
}
if (typeof window === "undefined") {
return;
}
const frame = window.requestAnimationFrame(() => {
repositionMenu();
});
const handlePointerDown = (event: PointerEvent): void => {
if (!menuRef?.contains(event.target as Node)) {
closeMenu();
}
};
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key === "Escape") {
closeMenu();
}
};
const handleViewportChange = (): void => {
closeMenu();
};
document.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("resize", handleViewportChange);
window.addEventListener("scroll", handleViewportChange, true);
window.addEventListener("keydown", handleKeyDown);
onCleanup(() => {
window.cancelAnimationFrame(frame);
document.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("resize", handleViewportChange);
window.removeEventListener("scroll", handleViewportChange, true);
window.removeEventListener("keydown", handleKeyDown);
});
});
return {
menuState,
openMenu,
openMenuFromElement,
closeMenu,
setMenuRef: (element: HTMLDivElement): void => {
menuRef = element;
},
};
};

View File

@@ -0,0 +1,146 @@
.layer {
display: none;
}
@include respond-down(mobile) {
.layer {
position: fixed;
inset: 0;
display: block;
z-index: var(--z-modal);
}
.backdrop {
position: absolute;
inset: 0;
border: 0;
background: color-mix(in srgb, black 52%, transparent);
}
.sheet {
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;
}
.handle {
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);
}
.header {
display: flex;
align-items: start;
justify-content: space-between;
gap: var(--space-3);
padding-bottom: var(--space-3);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent);
}
.headerCopy {
min-width: 0;
display: grid;
gap: calc(var(--space-1) / 2);
}
.eyebrow {
@include text-caption;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.title {
@include text-title;
color: var(--color-text);
font-weight: var(--font-weight-semibold);
}
.closeButton {
flex: 0 0 auto;
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) 88%, transparent);
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--color-surface) 84%, var(--color-surface-elevated, var(--color-surface)) 16%);
color: var(--color-text-subtle);
}
.sectionList {
display: grid;
gap: var(--space-3);
}
.section {
display: grid;
gap: var(--space-2);
}
.section + .section {
padding-top: var(--space-3);
border-top: 1px solid color-mix(in srgb, var(--color-border) 92%, transparent);
}
.sectionLabel {
@include text-caption;
padding-inline: var(--space-1);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.actionList {
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%);
}
.action {
min-width: 0;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
min-height: calc(var(--control-size-lg) + var(--space-2));
padding: 0 var(--space-3);
border: 0;
background: transparent;
color: var(--color-text);
text-align: left;
}
.action:active {
background: color-mix(in srgb, var(--color-text) 6%, transparent);
}
.action + .action {
border-top: 1px solid color-mix(in srgb, var(--color-border) 92%, transparent);
}
.actionLabel {
@include text-label;
}
.actionDanger {
color: var(--color-danger-text, var(--color-text));
}
}

View File

@@ -0,0 +1,134 @@
import { For, Show, createMemo, type JSX } from "solid-js";
import { Portal } from "solid-js/web";
import { X } from "../../../lib/icons";
import {
getWorkspaceContextMenuEyebrow,
getWorkspaceContextMenuSections,
type WorkspaceContextMenuAction,
type WorkspaceContextMenuSection,
type WorkspaceContextMenuTarget,
} from "../data/shell.data";
import styles from "./WorkspaceMobileActionSheet.module.scss";
type WorkspaceMobileActionSheetProps = {
target: WorkspaceContextMenuTarget | null;
onClose: VoidFunction;
onSelect: (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget) => void;
};
type FlattenedActionSection = {
id: string;
label?: string;
items: readonly WorkspaceContextMenuAction[];
};
const flattenMobileSections = (
sections: readonly WorkspaceContextMenuSection[],
): readonly FlattenedActionSection[] => {
// Mobile uses a flat action-sheet model, so desktop flyout groups become
// standalone labeled sections instead of nested menus.
return sections.flatMap((section) => {
const directActions = section.items.filter((action) => !action.children?.length);
const nestedSections = section.items
.filter((action) => action.children?.length)
.map((action) => ({
id: `${section.id}-${action.id}`,
label: action.label,
items: action.children ?? [],
}));
const flattenedSections: FlattenedActionSection[] = [];
if (directActions.length > 0) {
flattenedSections.push({
id: section.id,
label: section.label,
items: directActions,
});
}
return [...flattenedSections, ...nestedSections];
});
};
export const WorkspaceMobileActionSheet = (props: WorkspaceMobileActionSheetProps): JSX.Element => {
const sheetState = createMemo(() => {
if (!props.target) {
return null;
}
return {
target: props.target,
sections: flattenMobileSections(getWorkspaceContextMenuSections(props.target)),
};
});
const handleActionSelect = (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget): void => {
props.onSelect(action, target);
props.onClose();
};
return (
<Show when={sheetState()}>
{(sheetState): JSX.Element => {
const target = sheetState().target;
const sections = sheetState().sections;
return (
<Portal>
<div class={styles.layer} data-ui="workspace-mobile-action-sheet" data-target-kind={target.kind} data-item-type={target.kind === "item" ? target.itemType : undefined}>
<button class={styles.backdrop} type="button" aria-label="Close action sheet" data-slot="mobile-action-sheet-backdrop" onClick={props.onClose} />
<section class={styles.sheet} aria-label={`${target.label} actions`} data-slot="mobile-action-sheet-panel">
<div class={styles.handle} data-slot="mobile-action-sheet-handle" aria-hidden="true" />
<header class={styles.header} data-slot="mobile-action-sheet-header">
<div class={styles.headerCopy} data-slot="mobile-action-sheet-header-copy">
<span class={styles.eyebrow}>{getWorkspaceContextMenuEyebrow(target)}</span>
<strong class={styles.title}>{target.label}</strong>
</div>
<button class={styles.closeButton} type="button" aria-label="Close action sheet" data-slot="mobile-action-sheet-close" onClick={props.onClose}>
<X size={18} strokeWidth={2} />
</button>
</header>
<div class={styles.sectionList} data-slot="mobile-action-sheet-sections">
<For each={sections}>
{(section): JSX.Element => (
<section class={styles.section} data-slot="mobile-action-sheet-section" data-section-id={section.id}>
<Show when={section.label}>
<span class={styles.sectionLabel}>{section.label}</span>
</Show>
<div class={styles.actionList} data-slot="mobile-action-sheet-action-list">
<For each={section.items}>
{(action): JSX.Element => (
<button
type="button"
classList={{
[styles.action]: true,
[styles.actionDanger]: action.tone === "danger",
}}
data-slot="mobile-action-sheet-action"
data-action-id={action.id}
data-tone={action.tone ?? "default"}
onClick={() => handleActionSelect(action, target)}
>
<span class={styles.actionLabel}>{action.label}</span>
</button>
)}
</For>
</div>
</section>
)}
</For>
</div>
</section>
</div>
</Portal>
);
}}
</Show>
);
};

View File

@@ -1,6 +1,5 @@
.sidebar {
--sidebar-nav-item-min-height: var(--control-size-lg);
--sidebar-dock-clearance: 8rem;
position: relative;
min-width: 0;
min-height: 0;
@@ -15,13 +14,60 @@
.header {
display: grid;
gap: var(--space-3);
}
.headerActions {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--space-2);
justify-items: stretch;
}
.headerControls {
display: grid;
grid-template-columns: minmax(0, 1fr);
align-items: start;
}
.headerDrawerOpen {
z-index: 4;
}
.headerActionButton {
width: 100%;
min-height: var(--control-size-md);
display: inline-flex;
align-items: center;
justify-content: center;
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);
}
.headerActionButton:hover,
.headerActionButton:focus-visible {
background: var(--color-surface-hover);
border-color: var(--color-border);
color: var(--color-text);
}
.headerActionButton:hover {
transform: translateY(-1px);
}
.headerCollapseButton {
background: color-mix(in srgb, var(--color-accent-soft) 58%, transparent);
color: var(--color-accent-strong);
}
.section {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
@@ -45,7 +91,7 @@
overflow-y: auto;
overscroll-behavior: contain;
padding-right: var(--space-1);
padding-bottom: calc(var(--space-4) + var(--sidebar-dock-clearance));
padding-bottom: calc(var(--space-4) + var(--sidebar-dock-clearance, var(--shell-dock-clearance)));
margin-right: calc(var(--space-1) * -1);
}
@@ -61,6 +107,22 @@
padding: 0;
}
.treeSectionLabel {
@include text-caption;
margin: var(--space-3) 0 var(--space-2);
padding: 0 var(--space-3);
color: var(--color-text-subtle);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.treeList {
list-style: none;
display: grid;
gap: var(--space-1);
padding: 0;
}
.navItem {
width: 100%;
min-width: 0;
@@ -75,6 +137,45 @@
@include interactive-frame-hover(var(--color-surface-hover), transparent, var(--color-text));
}
.treeItem {
width: 100%;
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-2);
min-height: calc(var(--control-size-lg) - var(--space-2));
padding: var(--space-2) var(--space-3);
padding-left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4)));
border: 1px solid transparent;
border-radius: var(--radius-lg);
background: transparent;
color: var(--color-text-muted);
text-align: left;
transition:
background 160ms var(--easing-standard),
color 160ms var(--easing-standard),
border-color 160ms var(--easing-standard),
transform 180ms var(--easing-standard);
}
.treeItem:hover,
.treeItem:focus-visible {
background: var(--color-surface-hover);
color: var(--color-text);
}
.treeItemFolder {
color: var(--color-text);
}
.treeItemActive {
border-color: var(--color-border);
background: var(--color-surface);
color: var(--color-text);
box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent);
}
.navItemActive {
border-color: var(--color-border);
background: var(--color-surface);
@@ -97,6 +198,52 @@
color: var(--color-text-muted);
}
.sidebarCollapsed {
gap: var(--space-3);
padding: var(--space-3) var(--space-2);
}
.sidebarCollapsed .headerActions {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.sidebarCollapsed .headerControls {
justify-items: center;
}
.sidebarCollapsed .header {
justify-items: center;
}
.sidebarCollapsed .navScroller {
padding-right: 0;
margin-right: 0;
}
.sidebarCollapsed .navItem {
grid-template-columns: auto;
justify-content: center;
gap: 0;
min-height: calc(var(--control-size-lg) - var(--space-1));
padding: var(--space-2);
border-radius: var(--radius-md);
}
.sidebarCollapsed .label,
.sidebarCollapsed .itemMeta,
.sidebarCollapsed .treeSectionLabel,
.sidebarCollapsed .treeList {
display: none;
}
.sidebarCollapsed .icon {
opacity: 1;
}
.sidebarCollapsed .section {
gap: var(--space-3);
}
@include respond-down(mobile) {
.sidebar {
display: none;

View File

@@ -1,67 +1,277 @@
// Path: Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx
import { For, Show, createSignal, type JSX } from "solid-js";
import { For, Show, createMemo, createSignal, type JSX } from "solid-js";
import { ChevronLeft, ChevronRight } from "../../../lib/icons";
import { useAppShellData } from "../data/app-shell.context";
import { ProjectSelector } from "../ProjectSelector/ProjectSelector";
import { serverSidebarItems } from "../data/shell.data";
import {
createWorkspaceStaticTarget,
createWorkspaceSurfaceTarget,
createWorkspaceTreeTarget,
getWorkspaceNodeIcon,
workspaceSidebarHeaderActions,
workspaceStaticItems,
type WorkspaceContextMenuAction,
type WorkspaceContextMenuTarget,
type WorkspaceStaticItem,
type WorkspaceTreeNode,
} from "../data/shell.data";
import { WorkspaceContextMenu } from "../WorkspaceContextMenu/WorkspaceContextMenu";
import { createWorkspaceContextMenuController } from "../WorkspaceContextMenu/createWorkspaceContextMenuController";
import styles from "./WorkspaceSidebar.module.scss";
export const WorkspaceSidebar = (): JSX.Element => {
const [isProjectDrawerOpen, setIsProjectDrawerOpen] = createSignal(false);
type WorkspaceSidebarProps = {
collapsed: boolean;
railCollapsed: boolean;
onToggleRailCollapse: () => void;
};
const isContextMenuKeyboardTrigger = (event: KeyboardEvent): boolean => event.key === "ContextMenu" || (event.shiftKey && event.key === "F10");
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 (
<aside class={styles.sidebar} aria-label="Server navigation">
<div
<li>
<button
type="button"
classList={{
[styles.header]: true,
[styles.headerDrawerOpen]: isProjectDrawerOpen(),
[styles.navItem]: true,
[styles.navItemActive]: !!props.item.active,
}}
>
<ProjectSelector
isOpen={isProjectDrawerOpen()}
onToggle={(): void => {
setIsProjectDrawerOpen(true);
}}
onClose={(): void => {
setIsProjectDrawerOpen(false);
}}
/>
</div>
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;
}
<div
classList={{
[styles.section]: true,
[styles.sectionHidden]: isProjectDrawerOpen(),
event.preventDefault();
props.onOpenContextMenuFromKeyboard(event.currentTarget, target);
}}
>
<span class={styles.sectionLabel}>Navigation</span>
<div class={styles.navScroller}>
<ul class={styles.navList} role="list">
<For each={serverSidebarItems}>
{(item): JSX.Element => {
const Icon = item.icon;
<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>
);
};
const WorkspaceTreeBranch = (props: {
nodes: readonly WorkspaceTreeNode[];
depth?: number;
onOpenContextMenu: (event: MouseEvent, target: WorkspaceContextMenuTarget) => void;
onOpenContextMenuFromKeyboard: (element: HTMLElement, target: WorkspaceContextMenuTarget) => void;
}): JSX.Element => {
const depth = () => props.depth ?? 0;
return (
<ul class={styles.treeList} role="list">
<For each={props.nodes}>
{(node): JSX.Element => {
const Icon = getWorkspaceNodeIcon(node);
const target = createWorkspaceTreeTarget(node);
return (
<li>
<button
type="button"
classList={{
[styles.treeItem]: true,
[styles.treeItemActive]: !!node.active,
[styles.treeItemFolder]: node.kind === "folder",
}}
style={{ "--tree-depth": String(depth()) }}
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"}
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}>{node.label}</span>
<Show when={node.meta}>
<span class={styles.itemMeta}>{node.meta}</span>
</Show>
</button>
<Show when={node.children?.length}>
<WorkspaceTreeBranch
nodes={node.children ?? []}
depth={depth() + 1}
onOpenContextMenu={props.onOpenContextMenu}
onOpenContextMenuFromKeyboard={props.onOpenContextMenuFromKeyboard}
/>
</Show>
</li>
);
}}
</For>
</ul>
);
};
export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
const appShellData = useAppShellData();
const [isProjectDrawerOpen, setIsProjectDrawerOpen] = createSignal(false);
const contextMenu = createWorkspaceContextMenuController();
const railToggleLabel = (): string => (props.railCollapsed ? "Expand server rail" : "Collapse server rail");
const sidebarContextMenuTarget = createMemo(() => createWorkspaceSurfaceTarget(appShellData.activeProject()));
const contextMenuTarget = createMemo(() => contextMenu.menuState()?.target ?? null);
const contextMenuPosition = createMemo(() => {
const state = contextMenu.menuState();
return state
? {
x: state.x,
y: state.y,
}
: null;
});
const handleContextActionSelect = (_action: WorkspaceContextMenuAction, _target: WorkspaceContextMenuTarget): void => {
// Initial implementation only establishes the menu IA and placement.
};
return (
<>
<aside
classList={{
[styles.sidebar]: true,
[styles.sidebarCollapsed]: props.collapsed,
}}
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 Icon = action.icon;
return (
<li>
<button
type="button"
classList={{
[styles.navItem]: true,
[styles.navItemActive]: !!item.active,
}}
>
<Icon class={styles.icon} size={18} strokeWidth={2} />
<span class={styles.label}>{item.label}</span>
<Show when={item.meta}>
<span class={styles.itemMeta}>{item.meta}</span>
</Show>
</button>
</li>
<button type="button" class={styles.headerActionButton} aria-label={action.label} title={action.label} data-slot="workspace-sidebar-header-action" data-action-id={action.id}>
<Icon size={16} strokeWidth={2} />
</button>
);
}}
</For>
</ul>
</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>
</aside>
<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.treeSectionLabel}>Items</div>
</Show>
<div data-slot="workspace-tree-root">
<WorkspaceTreeBranch
nodes={appShellData.workspaceTree()}
onOpenContextMenu={contextMenu.openMenu}
onOpenContextMenuFromKeyboard={contextMenu.openMenuFromElement}
/>
</div>
</div>
</div>
</aside>
<WorkspaceContextMenu
target={contextMenuTarget()}
position={contextMenuPosition()}
menuRef={contextMenu.setMenuRef}
onClose={contextMenu.closeMenu}
onSelect={handleContextActionSelect}
/>
</>
);
};

View File

@@ -0,0 +1,73 @@
import type { JSX } from "solid-js";
type PointerHandler = NonNullable<JSX.DOMAttributes<Element>["onPointerDown"]>;
type LongPressGestureOptions = {
onLongPress: () => void;
delay?: number;
movementThreshold?: number;
};
type LongPressGestureHandlers = {
onPointerDown: PointerHandler;
onPointerMove: PointerHandler;
onPointerUp: PointerHandler;
onPointerCancel: PointerHandler;
onPointerLeave: PointerHandler;
};
export const createLongPressGesture = (options: LongPressGestureOptions): LongPressGestureHandlers => {
let timeoutId: number | undefined;
let originX = 0;
let originY = 0;
const delay = options.delay ?? 420;
const movementThreshold = options.movementThreshold ?? 10;
const clearPendingLongPress = (): void => {
if (typeof timeoutId === "number") {
window.clearTimeout(timeoutId);
timeoutId = undefined;
}
};
const onPointerDown: PointerHandler = (event): void => {
// Mobile long-press should only respond to the primary touch/pen pointer.
if (event.pointerType === "mouse" || !event.isPrimary) {
return;
}
originX = event.clientX;
originY = event.clientY;
clearPendingLongPress();
timeoutId = window.setTimeout(() => {
timeoutId = undefined;
options.onLongPress();
}, delay);
};
const onPointerMove: PointerHandler = (event): void => {
if (typeof timeoutId !== "number") {
return;
}
const deltaX = Math.abs(event.clientX - originX);
const deltaY = Math.abs(event.clientY - originY);
if (deltaX > movementThreshold || deltaY > movementThreshold) {
clearPendingLongPress();
}
};
const onPointerUp: PointerHandler = (): void => {
clearPendingLongPress();
};
return {
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel: onPointerUp,
onPointerLeave: onPointerUp,
};
};

View File

@@ -0,0 +1,317 @@
// 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;
mode: "personal" | "organizational" | string;
access: string;
protocol: string;
host: string;
isBootstrapped: boolean;
};
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[];
};
type AppShellContextValue = {
status: Accessor<"idle" | "loading" | "success" | "error">;
error: Accessor<string>;
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";
return payload.organizations.map((organization, index) => ({
id: organization.id,
label: organization.name,
abbreviation: buildAbbreviation(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";
return {
id: installation.id,
name: organization.name || installation.host || fallbackActiveServer.name,
abbreviation: buildAbbreviation(organization.name || installation.host, 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",
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.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 } };
if (!response.ok || !body.data) {
throw new Error(body.error?.message || "Failed to load app shell state.");
}
setPayload(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,
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;
};

View File

@@ -4,6 +4,7 @@ import type { Component } from "solid-js";
import {
Bell,
CircleHelp,
FileText,
Folder,
Home,
Keyboard,
@@ -81,12 +82,222 @@ export type SidebarItem = {
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;
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;
@@ -114,12 +325,12 @@ export type ActiveUserProfile = {
contextLabel: string;
};
const personalDockActions: readonly ServerDockAction[] = [
export const personalDockActions: readonly ServerDockAction[] = [
{ id: "account", label: "Account", icon: User },
{ id: "settings", label: "Settings", icon: Settings },
] as const;
const organizationAdminDockActions: readonly ServerDockAction[] = [
export const organizationAdminDockActions: readonly ServerDockAction[] = [
{ id: "members", label: "Members", icon: User },
{ id: "server", label: "Server", icon: Settings },
] as const;
@@ -165,13 +376,163 @@ export const departmentItems: readonly DepartmentItem[] = [
] as const;
// Sidebar and topbar scaffold data
export const serverSidebarItems: readonly SidebarItem[] = [
{ id: "home", label: "Home", icon: Home, active: true },
{ id: "boards", label: "Boards", icon: LayoutGrid, meta: "0" },
{ id: "docs", label: "Docs", icon: Folder, meta: "0" },
{ id: "settings", label: "Settings", icon: Settings },
// 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 },
] 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;
}
}
};
export const topBarActions: readonly TopBarAction[] = [
{ id: "search", label: "Search", icon: Search },
] as const;

View File

@@ -1,6 +1,11 @@
.viewport {
.viewport,
.wizardLayer {
--workspace-content-max-width: var(--content-width-wide);
--workspace-card-min-height: calc(var(--space-12) * 3);
--bootstrap-accent: var(--color-accent-primary, var(--color-primary-2, hsl(272 80% 70%)));
--bootstrap-accent-contrast: var(--color-accent-primary-contrast, white);
}
.viewport {
min-width: 0;
min-height: 0;
display: grid;
@@ -9,6 +14,71 @@
padding: var(--space-5) var(--space-6);
}
.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: 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);
}
.hero {
display: grid;
gap: var(--space-3);
@@ -16,6 +86,12 @@
max-width: var(--workspace-content-max-width);
}
.heroActions {
display: flex;
gap: var(--space-3);
flex-wrap: wrap;
}
.eyebrow {
@include text-caption;
color: var(--color-text-muted);
@@ -33,18 +109,12 @@
color: var(--color-text-muted);
}
.grid {
width: 100%;
max-width: var(--workspace-content-max-width);
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-4);
}
.card {
.overviewCard,
.summaryCard,
.wizardSidebarSection,
.wizardStepPanel {
display: grid;
gap: var(--space-2);
min-height: var(--workspace-card-min-height);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
@@ -52,28 +122,410 @@
box-shadow: var(--shadow-soft);
}
.cardTitle {
.overviewCard {
width: 100%;
max-width: var(--workspace-content-max-width);
gap: var(--space-3);
}
.summaryGrid {
width: 100%;
max-width: var(--workspace-content-max-width);
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
}
.summaryCard {
min-height: 0;
}
.summaryCardHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.summaryStepNumber,
.wizardStepEyebrow {
@include text-caption;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.sectionHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
}
.sectionTitle,
.cardTitle,
.wizardTitle,
.sidebarTitle {
@include text-title;
}
.cardCopy {
.sectionCopy,
.cardCopy,
.cardMeta {
color: var(--color-text-muted);
}
.statusBadge {
@include text-caption;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: calc(var(--control-size-sm) - 0.25rem);
padding: 0 var(--space-2);
border-radius: var(--radius-pill);
border: 1px solid color-mix(in srgb, var(--color-border-strong) 44%, transparent);
background: color-mix(in srgb, var(--color-surface-secondary) 92%, transparent);
color: var(--color-text-muted);
white-space: nowrap;
}
.statusBadge[data-status="submitting"] {
color: var(--bootstrap-accent);
border-color: color-mix(in srgb, var(--bootstrap-accent) 38%, transparent);
}
.statusBadge[data-status="success"] {
color: var(--color-success-text, var(--color-text));
border-color: color-mix(in srgb, var(--color-success-border, var(--color-border)) 64%, transparent);
background: color-mix(in srgb, var(--color-success-surface, var(--color-surface-secondary)) 80%, transparent);
}
.statusBadge[data-status="error"] {
color: var(--color-danger-text, var(--color-text));
border-color: color-mix(in srgb, var(--color-danger-border, var(--color-border)) 68%, transparent);
background: color-mix(in srgb, var(--color-danger-surface, var(--color-surface-secondary)) 80%, transparent);
}
.endpointMeta {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.endpointMeta span {
@include text-caption;
color: var(--color-text-muted);
text-transform: uppercase;
}
.endpointMeta code,
.cardMeta {
@include text-caption;
}
.endpointMeta code {
padding: 0.125rem 0.5rem;
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--color-surface-secondary) 90%, transparent);
color: var(--color-text);
}
.form {
display: grid;
gap: var(--space-3);
}
.field {
display: grid;
gap: var(--space-1);
}
.fieldLabel {
@include text-caption;
color: var(--color-text-muted);
}
.field input,
.field select {
min-height: var(--control-size-md);
width: 100%;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface-elevated);
color: var(--color-text);
padding: 0 var(--space-3);
transition:
border-color 160ms var(--easing-standard),
box-shadow 160ms var(--easing-standard),
background 160ms var(--easing-standard);
}
.field input:focus-visible,
.field select:focus-visible {
outline: none;
border-color: color-mix(in srgb, var(--bootstrap-accent) 60%, var(--color-border));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--bootstrap-accent) 16%, transparent);
}
.wizardFormActions,
.heroActions,
.primaryButton,
.secondaryButton {
display: flex;
align-items: center;
}
.wizardFormActions {
justify-content: space-between;
gap: var(--space-3);
flex-wrap: nowrap;
}
.wizardFormActions .primaryButton {
margin-left: auto;
}
.primaryButton,
.secondaryButton,
.wizardStepButton,
.wizardCloseButton {
appearance: none;
justify-content: center;
gap: var(--space-2);
min-height: var(--control-size-md);
border-radius: var(--radius-pill);
font-weight: 600;
cursor: pointer;
transition:
transform 180ms var(--easing-standard),
background 160ms var(--easing-standard),
border-color 160ms var(--easing-standard),
color 160ms var(--easing-standard),
box-shadow 160ms var(--easing-standard);
}
.primaryButton,
.secondaryButton,
.wizardCloseButton {
padding: 0 var(--space-4);
}
.primaryButton {
border: 1px solid color-mix(in srgb, var(--bootstrap-accent) 72%, black 8%);
background: linear-gradient(
180deg,
color-mix(in srgb, var(--bootstrap-accent) 88%, white 12%),
color-mix(in srgb, var(--bootstrap-accent) 92%, black 8%)
);
color: var(--bootstrap-accent-contrast);
box-shadow:
inset 0 1px 0 color-mix(in srgb, white 28%, transparent),
0 10px 24px color-mix(in srgb, var(--bootstrap-accent) 26%, transparent);
}
.secondaryButton,
.wizardCloseButton {
border: 1px solid var(--color-border);
background: var(--color-surface-secondary);
color: var(--color-text);
}
.primaryButton:hover,
.secondaryButton:hover,
.wizardCloseButton:hover,
.wizardStepButton:hover {
transform: translateY(-1px);
}
.primaryButton:hover,
.primaryButton:focus-visible {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--bootstrap-accent) 84%, white 16%),
color-mix(in srgb, var(--bootstrap-accent) 90%, black 10%)
);
border-color: color-mix(in srgb, var(--bootstrap-accent) 78%, black 10%);
box-shadow:
inset 0 1px 0 color-mix(in srgb, white 32%, transparent),
0 14px 32px color-mix(in srgb, var(--bootstrap-accent) 30%, transparent);
}
.primaryButton:disabled,
.secondaryButton:disabled {
opacity: 0.72;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.errorText {
@include text-caption;
color: var(--color-danger-text, var(--color-text));
}
.wizardLayer {
position: fixed;
inset: 0;
z-index: var(--z-modal);
}
.wizardBackdrop {
position: absolute;
inset: 0;
border: 0;
background: color-mix(in srgb, black 56%, transparent);
backdrop-filter: blur(var(--blur-overlay));
}
.wizardPanel {
position: relative;
z-index: 1;
width: min(calc(100vw - (var(--space-6) * 2)), 72rem);
max-height: calc(100dvh - (var(--space-6) * 2));
margin: var(--space-6) auto;
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-surface-elevated, var(--color-surface)) 6%);
box-shadow: var(--shadow-strong);
overflow: auto;
}
.wizardHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
}
.wizardHeaderCopy {
min-width: 0;
display: grid;
gap: var(--space-1);
}
.wizardBody {
display: grid;
grid-template-columns: minmax(17rem, 20rem) minmax(0, 1fr);
gap: var(--space-4);
min-height: 0;
}
.wizardSidebar {
display: grid;
gap: var(--space-4);
align-content: start;
}
.wizardSidebarSection {
gap: var(--space-3);
}
.wizardSteps {
display: grid;
gap: var(--space-2);
}
.wizardStepButton {
width: 100%;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
text-align: left;
padding: var(--space-2) var(--space-3);
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"] {
border-color: color-mix(in srgb, var(--bootstrap-accent) 42%, transparent);
background: color-mix(in srgb, var(--bootstrap-accent) 10%, var(--color-surface));
}
.wizardStepButton:disabled {
opacity: 0.56;
cursor: not-allowed;
transform: none;
}
.wizardStepIndex {
width: calc(var(--control-size-md) - var(--space-2));
height: calc(var(--control-size-md) - var(--space-2));
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--color-surface) 80%, transparent);
color: var(--color-text);
}
.wizardStepCopy {
min-width: 0;
display: grid;
gap: 0.125rem;
}
.wizardStepCopy strong {
@include text-label;
}
.wizardStepCopy small {
@include text-caption;
color: var(--color-text-muted);
}
.wizardStepPanel {
align-content: start;
gap: var(--space-3);
}
@include respond-down(tablet) {
.grid {
.summaryGrid,
.wizardBody {
grid-template-columns: 1fr;
}
.sectionHeader,
.wizardHeader {
display: grid;
}
}
@include respond-down(mobile) {
.viewport {
gap: var(--space-4);
padding: var(--space-4);
padding: var(--space-4) var(--space-4) calc(var(--space-8) + env(safe-area-inset-bottom, 0px));
}
.workspaceTopBar {
grid-template-columns: minmax(0, 1fr);
}
.workspaceTopBarStart,
.workspaceTopBarEnd,
.workspaceCollapseButton {
display: none;
}
.workspaceTopBarCenter {
justify-content: flex-start;
}
.summaryGrid {
grid-template-columns: 1fr;
}
.wizardPanel {
width: calc(100vw - (var(--space-4) * 2));
max-height: calc(100dvh - (var(--space-4) * 2));
margin: var(--space-4) auto;
padding: var(--space-3);
}
.wizardFormActions {
gap: var(--space-2);
}
}

View File

@@ -1,55 +1,473 @@
// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx
import { For, type JSX } from "solid-js";
import { activeServer } from "../../shell/data/shell.data";
import { For, Show, createMemo, createSignal, onMount, 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 ShellCheckpointCard = {
type BootstrapStepKey = "instance" | "mode" | "admin" | "structure";
type BootstrapStepDefinition = {
id: BootstrapStepKey;
title: string;
copy: string;
meta: string;
buttonLabel: string;
};
const shellCheckpointCards: readonly ShellCheckpointCard[] = [
type BootstrapSubmissionState = {
status: "idle" | "submitting" | "success" | "error";
error: string;
};
const bootstrapStepDefinitions: readonly BootstrapStepDefinition[] = [
{
title: "Server shell",
copy: "Top bar, server rail, sidebar, and content viewport are now split into modular components.",
meta: "Layout foundation",
id: "instance",
title: "Instance shape",
buttonLabel: "Save and continue",
},
{
title: "Presence foundation",
copy: "The dock now distinguishes personal and organization servers, leaving clear space for future presence and server-aware controls.",
meta: "Server foundation",
id: "mode",
title: "Server mode",
buttonLabel: "Save and continue",
},
{
title: "Next build target",
copy: "You can now plug in auth state, server onboarding, and live presence without redesigning the whole frame.",
meta: "Ready for v0.1.0 work",
id: "admin",
title: "Admin account",
buttonLabel: "Save and continue",
},
{
id: "structure",
title: "Initial structure",
buttonLabel: "Submit",
},
];
export const WorkspaceHome = (): JSX.Element => {
return (
<main class={styles.viewport}>
<section class={styles.hero}>
<span class={styles.eyebrow}>Server home</span>
<h1 class={styles.title}>{activeServer.name} is ready for its first real shell.</h1>
<p class={styles.description}>
This is the barebone app frame for v0.1.0 enough structure to start building a real self-hosted server experience on top of the backend core.
</p>
</section>
const bootstrapCompletionStorageKey = "moku.bootstrap.completed";
<section class={styles.grid} aria-label="Shell checkpoints">
<For each={shellCheckpointCards}>
{(card): JSX.Element => (
<article class={styles.card}>
<h2 class={styles.cardTitle}>{card.title}</h2>
<p class={styles.cardCopy}>{card.copy}</p>
<span class={styles.cardMeta}>{card.meta}</span>
</article>
)}
</For>
</section>
</main>
const initialSubmissionState = (): BootstrapSubmissionState => ({
status: "idle",
error: "",
});
const readBootstrapCompletion = (): boolean => {
if (typeof window === "undefined") {
return false;
}
return window.localStorage.getItem(bootstrapCompletionStorageKey) === "true";
};
const writeBootstrapCompletion = (isComplete: boolean): void => {
if (typeof window === "undefined") {
return;
}
if (isComplete) {
window.localStorage.setItem(bootstrapCompletionStorageKey, "true");
return;
}
window.localStorage.removeItem(bootstrapCompletionStorageKey);
};
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;
}
};
type WorkspaceHomeProps = {
sidebarCollapsed: boolean;
onToggleSidebarCollapse: () => void;
};
export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
const appShellData = useAppShellData();
const [instanceForm, setInstanceForm] = createStore({
protocol: "http",
access: "local",
host: "localhost",
});
const [modeForm, setModeForm] = createStore({
mode: "personal",
});
const [adminForm, setAdminForm] = createStore({
displayName: "Ronald",
email: "admin@example.com",
password: "",
});
const [structureForm, setStructureForm] = createStore({
departmentName: "Platform",
teamName: "Core",
projectName: "Moku",
});
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);
onMount(() => {
const isComplete = readBootstrapCompletion();
setIsBootstrapComplete(isComplete);
setIsWizardOpen(!isComplete);
setIsBootstrapStateResolved(true);
});
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 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 => isBootstrapComplete();
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(
typeof data?.error?.message === "string"
? data.error.message
: `Bootstrap ${step} request failed.`,
);
}
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()) {
writeBootstrapCompletion(true);
setIsBootstrapComplete(true);
setIsWizardOpen(false);
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">
<span class={styles.eyebrow}>Bootstrap</span>
<h1 class={styles.title}>{appShellData.activeServer().name}</h1>
<Show when={isBootstrapStateResolved() && !isBootstrapComplete()}>
<div class={styles.heroActions}>
<button type="button" class={styles.primaryButton} onClick={(): void => setIsWizardOpen(true)}>
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 {appShellData.activeServer().name}
</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)}>
<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)}>
<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)}>
<option value="personal">personal</option>
<option value="organizational">organizational</option>
</select>
</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="First 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="Temporary for echo testing"
/>
</label>
</>
</Show>
<Show when={currentStep().id === "structure"}>
<>
<label class={styles.field}>
<span class={styles.fieldLabel}>Department</span>
<input
type="text"
value={structureForm.departmentName}
onInput={(event): void => setStructureForm("departmentName", event.currentTarget.value)}
placeholder="Platform"
/>
</label>
<label class={styles.field}>
<span class={styles.fieldLabel}>Team</span>
<input
type="text"
value={structureForm.teamName}
onInput={(event): void => setStructureForm("teamName", event.currentTarget.value)}
placeholder="Core"
/>
</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>
</>
);
};

View File

@@ -1,3 +1,11 @@
// Path: Frontend/src/global.d.ts
/// <reference types="@solidjs/start/env" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

11
Frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,11 @@
// Path: Frontend/src/lib/api.ts
export const resolveAPIBase = (): string => {
const configuredBase = import.meta.env.VITE_API_BASE_URL?.trim();
if (!configuredBase) {
return "/v1";
}
return configuredBase.replace(/\/$/, "");
};

View File

@@ -3,6 +3,9 @@
export { default as Bell } from "lucide-solid/icons/bell";
export { default as CircleHelp } from "lucide-solid/icons/circle-help";
export { default as ChevronDown } from "lucide-solid/icons/chevron-down";
export { default as ChevronLeft } from "lucide-solid/icons/chevron-left";
export { default as ChevronRight } from "lucide-solid/icons/chevron-right";
export { default as FileText } from "lucide-solid/icons/file-text";
export { default as Folder } from "lucide-solid/icons/folder";
export { default as Home } from "lucide-solid/icons/house";
export { default as Keyboard } from "lucide-solid/icons/keyboard";
@@ -12,8 +15,8 @@ export { default as Moon } from "lucide-solid/icons/moon";
export { default as Plus } from "lucide-solid/icons/plus";
export { default as Repeat } from "lucide-solid/icons/repeat";
export { default as Search } from "lucide-solid/icons/search";
export { default as Server } from "lucide-solid/icons/server";
export { default as Settings } from "lucide-solid/icons/settings";
export { default as Shield } from "lucide-solid/icons/shield";
export { default as Sun } from "lucide-solid/icons/sun";
export { default as User } from "lucide-solid/icons/user";
export { default as X } from "lucide-solid/icons/x";

View File

@@ -0,0 +1,41 @@
/* Path: Frontend/src/styles/user-overrides.scss */
/*
Optional global frontend override seam.
This file is imported after the core app styles so user or deployment-specific
overrides can layer on top without reshaping component code first.
Examples for later:
- import a tenant branding bundle
- apply a self-hosted custom theme
- override shared shell spacing or color tokens
- target stable `data-ui`, `data-slot`, or state attributes added in the app shell
You can either place raw overrides here directly or layer another stylesheet:
@use "./my-brand" as *;
*/
/*
Stable override hooks are intentionally exposed with global data attributes so
manual overrides do not depend on CSS module hash names.
Examples:
[data-ui="top-bar"] {
backdrop-filter: blur(24px);
}
[data-ui="workspace-sidebar"] [data-slot="workspace-tree-item"][data-kind="item"] {
border-radius: 12px;
}
[data-ui="workspace-context-menu"] [data-action-id="delete"] {
letter-spacing: 0.01em;
}
[data-ui="notifications-menu"][data-variant="workspace"] {
max-width: none;
}
*/

View File

@@ -9,10 +9,22 @@ const extraAllowedHosts = (process.env.ALLOWED_HOSTS ?? "")
.map((host) => host.trim())
.filter(Boolean);
const backendAPIport = process.env.BACKEND_API_PORT?.trim() || "8081";
const devAPIProxyTarget =
process.env.FRONTEND_API_PROXY_TARGET?.trim() ||
process.env.VITE_DEV_PROXY_TARGET?.trim() ||
`http://api:${backendAPIport}`;
export default defineConfig({
plugins: [solidStart({ ssr: false })],
server: {
allowedHosts: ["localhost", ...extraAllowedHosts],
proxy: {
"/v1": {
target: devAPIProxyTarget,
changeOrigin: true,
},
},
},
preview: {
allowedHosts: ["localhost", ...extraAllowedHosts],

View File

@@ -25,6 +25,15 @@ server {
try_files $uri $uri/ $moku_bootstrap_document;
}
location ^~ /v1/ {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://api:8081;
}
location /favicon.ico {
try_files $uri =404;
access_log off;