Feat: Add collapsible shell
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -25,3 +25,5 @@ pnpm-debug.log*
|
|||||||
# Go build output
|
# Go build output
|
||||||
tmp/
|
tmp/
|
||||||
bin/
|
bin/
|
||||||
|
|
||||||
|
.cgcignore
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ type Config struct {
|
|||||||
LogLevel string
|
LogLevel string
|
||||||
WebPort string
|
WebPort string
|
||||||
APIPort string
|
APIPort string
|
||||||
WorkerPort string
|
|
||||||
PostgresURL string
|
PostgresURL string
|
||||||
ValkeyURL string
|
ValkeyURL string
|
||||||
ShutdownTimeout time.Duration
|
ShutdownTimeout time.Duration
|
||||||
@@ -28,7 +27,6 @@ func Load() *Config {
|
|||||||
LogLevel: getEnv("LOG_LEVEL", "debug"),
|
LogLevel: getEnv("LOG_LEVEL", "debug"),
|
||||||
WebPort: getEnv("BACKEND_WEB_PORT", "8080"),
|
WebPort: getEnv("BACKEND_WEB_PORT", "8080"),
|
||||||
APIPort: getEnv("BACKEND_API_PORT", "8081"),
|
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"),
|
PostgresURL: getEnv("DATABASE_URL", "postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable"),
|
||||||
ValkeyURL: getEnv("VALKEY_URL", "redis://localhost:6379/0"),
|
ValkeyURL: getEnv("VALKEY_URL", "redis://localhost:6379/0"),
|
||||||
ShutdownTimeout: getDurationEnv("BACKEND_SHUTDOWN_TIMEOUT", 10*time.Second),
|
ShutdownTimeout: getDurationEnv("BACKEND_SHUTDOWN_TIMEOUT", 10*time.Second),
|
||||||
@@ -43,8 +41,6 @@ func (c *Config) Address(serviceName string) string {
|
|||||||
port = c.WebPort
|
port = c.WebPort
|
||||||
case "api":
|
case "api":
|
||||||
port = c.APIPort
|
port = c.APIPort
|
||||||
case "worker":
|
|
||||||
port = c.WorkerPort
|
|
||||||
default:
|
default:
|
||||||
port = c.WebPort
|
port = c.WebPort
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ LOG_LEVEL=debug
|
|||||||
|
|
||||||
BACKEND_WEB_PORT=8080
|
BACKEND_WEB_PORT=8080
|
||||||
BACKEND_API_PORT=8081
|
BACKEND_API_PORT=8081
|
||||||
BACKEND_WORKER_PORT=8082
|
|
||||||
BACKEND_SHUTDOWN_TIMEOUT=10s
|
BACKEND_SHUTDOWN_TIMEOUT=10s
|
||||||
|
|
||||||
DATABASE_URL=postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable
|
DATABASE_URL=postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable
|
||||||
|
|||||||
@@ -23,6 +23,14 @@
|
|||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bodyRailCollapsed {
|
||||||
|
--rail-width: 0rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bodySidebarCollapsed {
|
||||||
|
--sidebar-width: 0rem;
|
||||||
|
}
|
||||||
|
|
||||||
.railColumn {
|
.railColumn {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -97,7 +105,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: var(--space-3);
|
bottom: var(--space-3);
|
||||||
left: calc(var(--space-1) + (var(--rail-width) * 0.1));
|
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;
|
right: auto;
|
||||||
z-index: calc(var(--z-modal) + 1);
|
z-index: calc(var(--z-modal) + 1);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -112,6 +120,14 @@
|
|||||||
--rail-width: 5rem;
|
--rail-width: 5rem;
|
||||||
--sidebar-width: 17.25rem;
|
--sidebar-width: 17.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bodyRailCollapsed {
|
||||||
|
--rail-width: 0rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bodySidebarCollapsed {
|
||||||
|
--sidebar-width: 0rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@include respond-down(tablet) {
|
@include respond-down(tablet) {
|
||||||
@@ -119,6 +135,58 @@
|
|||||||
--rail-width: 4.5rem;
|
--rail-width: 4.5rem;
|
||||||
--sidebar-width: 13.25rem;
|
--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) {
|
@include respond-down(mobile) {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import styles from "./AppShell.module.scss";
|
|||||||
|
|
||||||
export const AppShell = (): JSX.Element => {
|
export const AppShell = (): JSX.Element => {
|
||||||
const [themeState, setThemeState] = createSignal<Theme>("light");
|
const [themeState, setThemeState] = createSignal<Theme>("light");
|
||||||
|
const [isRailCollapsed, setIsRailCollapsed] = createSignal(false);
|
||||||
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = createSignal(false);
|
||||||
|
|
||||||
onMount((): void => {
|
onMount((): void => {
|
||||||
setThemeState(getDocumentTheme());
|
setThemeState(getDocumentTheme());
|
||||||
@@ -27,20 +29,37 @@ export const AppShell = (): JSX.Element => {
|
|||||||
<div class={styles.shell}>
|
<div class={styles.shell}>
|
||||||
<TopBar theme={themeState()} onToggleTheme={toggleTheme} />
|
<TopBar theme={themeState()} onToggleTheme={toggleTheme} />
|
||||||
|
|
||||||
<div class={styles.body}>
|
<div
|
||||||
|
classList={{
|
||||||
|
[styles.body]: true,
|
||||||
|
[styles.bodyRailCollapsed]: isRailCollapsed(),
|
||||||
|
[styles.bodySidebarCollapsed]: isSidebarCollapsed(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Left server rail */}
|
{/* Left server rail */}
|
||||||
<div class={styles.railColumn}>
|
<div class={styles.railColumn}>
|
||||||
<LeftRail />
|
<LeftRail collapsed={isRailCollapsed()} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar + main workspace frame */}
|
{/* Sidebar + main workspace frame */}
|
||||||
<div class={styles.workspaceRegion}>
|
<div class={styles.workspaceRegion}>
|
||||||
<div class={styles.sidebarColumn}>
|
<div class={styles.sidebarColumn}>
|
||||||
<WorkspaceSidebar />
|
<WorkspaceSidebar
|
||||||
|
collapsed={isSidebarCollapsed()}
|
||||||
|
railCollapsed={isRailCollapsed()}
|
||||||
|
onToggleRailCollapse={(): void => {
|
||||||
|
setIsRailCollapsed((collapsed) => !collapsed);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class={styles.workspaceMain}>
|
<div class={styles.workspaceMain}>
|
||||||
<WorkspaceHome />
|
<WorkspaceHome
|
||||||
|
sidebarCollapsed={isSidebarCollapsed()}
|
||||||
|
onToggleSidebarCollapse={(): void => {
|
||||||
|
setIsSidebarCollapsed((collapsed) => !collapsed);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
.rail {
|
.rail {
|
||||||
--rail-workspace-size: var(--control-size-lg);
|
--rail-workspace-size: var(--control-size-lg);
|
||||||
--rail-action-size: var(--control-size-md);
|
--rail-action-size: var(--control-size-md);
|
||||||
--rail-dock-clearance: 8rem;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -10,10 +9,19 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-3);
|
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, 8rem));
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.railCollapsed {
|
||||||
|
--rail-workspace-size: calc(var(--control-size-md) + 0.1rem);
|
||||||
|
--rail-action-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,
|
.topCluster,
|
||||||
.bottomCluster {
|
.bottomCluster {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -25,12 +33,22 @@
|
|||||||
|
|
||||||
.bottomCluster {
|
.bottomCluster {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
|
margin-bottom: var(--rail-bottom-offset, 0rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.topCluster {
|
.topCluster {
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.railCollapsed .topCluster {
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.railCollapsed .topCluster,
|
||||||
|
.railCollapsed .bottomCluster {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.items {
|
.items {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Path: Frontend/src/components/shell/LeftRail/LeftRail.tsx
|
// Path: Frontend/src/components/shell/LeftRail/LeftRail.tsx
|
||||||
|
|
||||||
import { For, type JSX } from "solid-js";
|
import { For, Show, type JSX } from "solid-js";
|
||||||
import { Plus } from "../../../lib/icons";
|
import { Plus } from "../../../lib/icons";
|
||||||
import { railItems, type RailItem } from "../data/shell.data";
|
import { railItems, type RailItem } from "../data/shell.data";
|
||||||
import styles from "./LeftRail.module.scss";
|
import styles from "./LeftRail.module.scss";
|
||||||
@@ -42,29 +42,49 @@ const RailEntry = (props: RailEntryProps): JSX.Element => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LeftRail = (): JSX.Element => {
|
type LeftRailProps = {
|
||||||
|
collapsed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LeftRail = (props: LeftRailProps): JSX.Element => {
|
||||||
const personalItem = railItems.find((item) => item.kind === "personal");
|
const personalItem = railItems.find((item) => item.kind === "personal");
|
||||||
const organizationItems = railItems.filter((item) => item.kind === "organization");
|
const organizationItems = railItems.filter((item) => item.kind === "organization");
|
||||||
|
|
||||||
return (
|
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}>
|
<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>
|
||||||
|
|
||||||
<div class={styles.items}>
|
<Show when={!props.collapsed}>
|
||||||
<For each={organizationItems}>
|
<div class={styles.items}>
|
||||||
{(item): JSX.Element => <RailEntry item={item} label={item.label} abbreviation={item.abbreviation} />}
|
<For each={organizationItems}>
|
||||||
</For>
|
{(item): JSX.Element => <RailEntry item={item} label={item.label} abbreviation={item.abbreviation} />}
|
||||||
</div>
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<div class={styles.bottomCluster}>
|
<Show when={!props.collapsed}>
|
||||||
<button type="button" class={styles.addButton} aria-label="Create server" title="Create server">
|
<div class={styles.bottomCluster}>
|
||||||
<Plus size={16} strokeWidth={2} />
|
<button type="button" class={styles.addButton} aria-label="Create server" title="Create server">
|
||||||
</button>
|
<Plus size={16} strokeWidth={2} />
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
--project-drawer-bottom: calc(var(--sidebar-dock-clearance) + var(--project-drawer-gap));
|
--project-drawer-bottom: calc(var(--sidebar-dock-clearance) + var(--project-drawer-gap));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rootCompact {
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.trigger {
|
.trigger {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -39,6 +43,29 @@
|
|||||||
box-shadow: var(--shadow-soft);
|
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 {
|
.triggerLead {
|
||||||
width: var(--control-size-md);
|
width: var(--control-size-md);
|
||||||
height: var(--control-size-md);
|
height: var(--control-size-md);
|
||||||
@@ -99,6 +126,13 @@
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rootCompact .scrim,
|
||||||
|
.rootCompact .drawer {
|
||||||
|
left: 0;
|
||||||
|
right: auto;
|
||||||
|
width: min(18rem, calc(100vw - 6rem));
|
||||||
|
}
|
||||||
|
|
||||||
.drawer {
|
.drawer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: calc(var(--project-drawer-top) + var(--project-drawer-gap)) var(--space-4)
|
inset: calc(var(--project-drawer-top) + var(--project-drawer-gap)) var(--space-4)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { activeProject, projectItems } from "../data/shell.data";
|
|||||||
import styles from "./ProjectSelector.module.scss";
|
import styles from "./ProjectSelector.module.scss";
|
||||||
|
|
||||||
type ProjectSelectorProps = {
|
type ProjectSelectorProps = {
|
||||||
|
compact?: boolean;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -68,7 +69,10 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={styles.root}
|
classList={{
|
||||||
|
[styles.root]: true,
|
||||||
|
[styles.rootCompact]: !!props.compact,
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
"--project-drawer-top": `${drawerTop()}px`,
|
"--project-drawer-top": `${drawerTop()}px`,
|
||||||
}}
|
}}
|
||||||
@@ -79,20 +83,23 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
|
|||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
classList={{
|
classList={{
|
||||||
[styles.trigger]: true,
|
[styles.trigger]: true,
|
||||||
|
[styles.triggerCompact]: !!props.compact,
|
||||||
[styles.triggerOpen]: props.isOpen,
|
[styles.triggerOpen]: props.isOpen,
|
||||||
}}
|
}}
|
||||||
aria-label="Open project drawer"
|
aria-label={`Open left workspace sidebar menu for ${selectedProject().name}`}
|
||||||
aria-expanded={props.isOpen}
|
aria-expanded={props.isOpen}
|
||||||
title="Open project drawer"
|
title={selectedProject().name}
|
||||||
onClick={toggleOpen}
|
onClick={toggleOpen}
|
||||||
>
|
>
|
||||||
<span class={styles.triggerLead} aria-hidden="true">
|
<span class={styles.triggerLead} aria-hidden="true">
|
||||||
<Folder size={18} strokeWidth={2} />
|
<Folder size={18} strokeWidth={2} />
|
||||||
</span>
|
</span>
|
||||||
<span class={styles.triggerCopy}>
|
{!props.compact ? (
|
||||||
<span class={styles.eyebrow}>Projects</span>
|
<span class={styles.triggerCopy}>
|
||||||
<span class={styles.value}>{selectedProject().name}</span>
|
<span class={styles.eyebrow}>Projects</span>
|
||||||
</span>
|
<span class={styles.value}>{selectedProject().name}</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
classList={{
|
classList={{
|
||||||
[styles.triggerIcon]: true,
|
[styles.triggerIcon]: true,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
.sidebar {
|
.sidebar {
|
||||||
--sidebar-nav-item-min-height: var(--control-size-lg);
|
--sidebar-nav-item-min-height: var(--control-size-lg);
|
||||||
--sidebar-dock-clearance: 8rem;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -15,13 +14,60 @@
|
|||||||
|
|
||||||
.header {
|
.header {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerActions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
|
justify-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerControls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.headerDrawerOpen {
|
.headerDrawerOpen {
|
||||||
z-index: 4;
|
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 {
|
.section {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
@@ -45,7 +91,7 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
padding-right: var(--space-1);
|
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, 8rem));
|
||||||
margin-right: calc(var(--space-1) * -1);
|
margin-right: calc(var(--space-1) * -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +143,50 @@
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebarCollapsed {
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3) var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarCollapsed .headerActions {
|
||||||
|
grid-template-columns: repeat(2, 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 {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarCollapsed .icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarCollapsed .section {
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
@include respond-down(mobile) {
|
@include respond-down(mobile) {
|
||||||
.sidebar {
|
.sidebar {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -1,30 +1,74 @@
|
|||||||
// Path: Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx
|
// Path: Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx
|
||||||
|
|
||||||
import { For, Show, createSignal, type JSX } from "solid-js";
|
import { For, Show, createSignal, type JSX } from "solid-js";
|
||||||
|
import { ChevronLeft, ChevronRight } from "../../../lib/icons";
|
||||||
import { ProjectSelector } from "../ProjectSelector/ProjectSelector";
|
import { ProjectSelector } from "../ProjectSelector/ProjectSelector";
|
||||||
import { serverSidebarItems } from "../data/shell.data";
|
import { serverSidebarItems, workspaceSidebarHeaderActions } from "../data/shell.data";
|
||||||
import styles from "./WorkspaceSidebar.module.scss";
|
import styles from "./WorkspaceSidebar.module.scss";
|
||||||
|
|
||||||
export const WorkspaceSidebar = (): JSX.Element => {
|
type WorkspaceSidebarProps = {
|
||||||
|
collapsed: boolean;
|
||||||
|
railCollapsed: boolean;
|
||||||
|
onToggleRailCollapse: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
|
||||||
const [isProjectDrawerOpen, setIsProjectDrawerOpen] = createSignal(false);
|
const [isProjectDrawerOpen, setIsProjectDrawerOpen] = createSignal(false);
|
||||||
|
const railToggleLabel = (): string => (props.railCollapsed ? "Expand server rail" : "Collapse server rail");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside class={styles.sidebar} aria-label="Server navigation">
|
<aside
|
||||||
|
classList={{
|
||||||
|
[styles.sidebar]: true,
|
||||||
|
[styles.sidebarCollapsed]: props.collapsed,
|
||||||
|
}}
|
||||||
|
aria-label="Left workspace sidebar"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
[styles.header]: true,
|
[styles.header]: true,
|
||||||
[styles.headerDrawerOpen]: isProjectDrawerOpen(),
|
[styles.headerDrawerOpen]: isProjectDrawerOpen(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ProjectSelector
|
<div class={styles.headerActions}>
|
||||||
isOpen={isProjectDrawerOpen()}
|
<button
|
||||||
onToggle={(): void => {
|
type="button"
|
||||||
setIsProjectDrawerOpen(true);
|
classList={{
|
||||||
}}
|
[styles.headerActionButton]: true,
|
||||||
onClose={(): void => {
|
[styles.headerCollapseButton]: true,
|
||||||
setIsProjectDrawerOpen(false);
|
}}
|
||||||
}}
|
aria-label={railToggleLabel()}
|
||||||
/>
|
title={railToggleLabel()}
|
||||||
|
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 (
|
||||||
|
<button type="button" class={styles.headerActionButton} aria-label={action.label} title={action.label}>
|
||||||
|
<Icon size={16} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles.headerControls}>
|
||||||
|
<ProjectSelector
|
||||||
|
compact={props.collapsed}
|
||||||
|
isOpen={isProjectDrawerOpen()}
|
||||||
|
onToggle={(): void => {
|
||||||
|
setIsProjectDrawerOpen(true);
|
||||||
|
}}
|
||||||
|
onClose={(): void => {
|
||||||
|
setIsProjectDrawerOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -33,7 +77,9 @@ export const WorkspaceSidebar = (): JSX.Element => {
|
|||||||
[styles.sectionHidden]: isProjectDrawerOpen(),
|
[styles.sectionHidden]: isProjectDrawerOpen(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span class={styles.sectionLabel}>Navigation</span>
|
<Show when={!props.collapsed}>
|
||||||
|
<span class={styles.sectionLabel}>Navigation</span>
|
||||||
|
</Show>
|
||||||
<div class={styles.navScroller}>
|
<div class={styles.navScroller}>
|
||||||
<ul class={styles.navList} role="list">
|
<ul class={styles.navList} role="list">
|
||||||
<For each={serverSidebarItems}>
|
<For each={serverSidebarItems}>
|
||||||
@@ -48,6 +94,8 @@ export const WorkspaceSidebar = (): JSX.Element => {
|
|||||||
[styles.navItem]: true,
|
[styles.navItem]: true,
|
||||||
[styles.navItemActive]: !!item.active,
|
[styles.navItemActive]: !!item.active,
|
||||||
}}
|
}}
|
||||||
|
aria-label={item.label}
|
||||||
|
title={item.label}
|
||||||
>
|
>
|
||||||
<Icon class={styles.icon} size={18} strokeWidth={2} />
|
<Icon class={styles.icon} size={18} strokeWidth={2} />
|
||||||
<span class={styles.label}>{item.label}</span>
|
<span class={styles.label}>{item.label}</span>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Keyboard,
|
Keyboard,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
Plus,
|
||||||
Repeat,
|
Repeat,
|
||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -81,6 +82,12 @@ export type SidebarItem = {
|
|||||||
meta?: string;
|
meta?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SidebarHeaderAction = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: ShellIcon;
|
||||||
|
};
|
||||||
|
|
||||||
export type TopBarAction = {
|
export type TopBarAction = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -172,6 +179,12 @@ export const serverSidebarItems: readonly SidebarItem[] = [
|
|||||||
{ id: "settings", label: "Settings", icon: Settings },
|
{ id: "settings", label: "Settings", icon: Settings },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export const workspaceSidebarHeaderActions: readonly SidebarHeaderAction[] = [
|
||||||
|
{ id: "workspace-settings", label: "Workspace settings", icon: Settings },
|
||||||
|
{ id: "search-workspace", label: "Search workspace", icon: Search },
|
||||||
|
{ id: "create-board", label: "Create board", icon: Plus },
|
||||||
|
] as const;
|
||||||
|
|
||||||
export const topBarActions: readonly TopBarAction[] = [
|
export const topBarActions: readonly TopBarAction[] = [
|
||||||
{ id: "search", label: "Search", icon: Search },
|
{ id: "search", label: "Search", icon: Search },
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -9,6 +9,71 @@
|
|||||||
padding: var(--space-5) var(--space-6);
|
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 {
|
.hero {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
@@ -76,4 +141,21 @@
|
|||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workspaceTopBar {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspaceTopBarEnd {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspaceTopBarCenter {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspaceCollapseButton {
|
||||||
|
width: calc(var(--control-size-md) - 0.5rem);
|
||||||
|
height: calc(var(--control-size-md) - 0.5rem);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx
|
// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx
|
||||||
|
|
||||||
import { For, type JSX } from "solid-js";
|
import { For, type JSX } from "solid-js";
|
||||||
import { activeServer } from "../../shell/data/shell.data";
|
import { ChevronLeft, ChevronRight } from "../../../lib/icons";
|
||||||
|
import { activeProject, activeServer } from "../../shell/data/shell.data";
|
||||||
import styles from "./WorkspaceHome.module.scss";
|
import styles from "./WorkspaceHome.module.scss";
|
||||||
|
|
||||||
type ShellCheckpointCard = {
|
type ShellCheckpointCard = {
|
||||||
@@ -28,9 +29,37 @@ const shellCheckpointCards: readonly ShellCheckpointCard[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const WorkspaceHome = (): JSX.Element => {
|
type WorkspaceHomeProps = {
|
||||||
|
sidebarCollapsed: boolean;
|
||||||
|
onToggleSidebarCollapse: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
|
||||||
|
const sidebarToggleLabel = (): string => (props.sidebarCollapsed ? "Expand left workspace sidebar" : "Collapse left workspace sidebar");
|
||||||
|
const breadcrumb = (): string => `${activeServer.name} / ${activeProject.name} / Home`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main class={styles.viewport}>
|
<main class={styles.viewport}>
|
||||||
|
<div class={styles.workspaceTopBar}>
|
||||||
|
<div class={styles.workspaceTopBarStart}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={styles.workspaceCollapseButton}
|
||||||
|
aria-label={sidebarToggleLabel()}
|
||||||
|
title={sidebarToggleLabel()}
|
||||||
|
onClick={props.onToggleSidebarCollapse}
|
||||||
|
>
|
||||||
|
{props.sidebarCollapsed ? <ChevronRight size={16} strokeWidth={2} /> : <ChevronLeft size={16} strokeWidth={2} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles.workspaceTopBarCenter}>
|
||||||
|
<span class={styles.workspaceBreadcrumb}>{breadcrumb()}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles.workspaceTopBarEnd} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class={styles.hero}>
|
<section class={styles.hero}>
|
||||||
<span class={styles.eyebrow}>Server home</span>
|
<span class={styles.eyebrow}>Server home</span>
|
||||||
<h1 class={styles.title}>{activeServer.name} is ready for its first real shell.</h1>
|
<h1 class={styles.title}>{activeServer.name} is ready for its first real shell.</h1>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
export { default as Bell } from "lucide-solid/icons/bell";
|
export { default as Bell } from "lucide-solid/icons/bell";
|
||||||
export { default as CircleHelp } from "lucide-solid/icons/circle-help";
|
export { default as CircleHelp } from "lucide-solid/icons/circle-help";
|
||||||
export { default as ChevronDown } from "lucide-solid/icons/chevron-down";
|
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 Folder } from "lucide-solid/icons/folder";
|
export { default as Folder } from "lucide-solid/icons/folder";
|
||||||
export { default as Home } from "lucide-solid/icons/house";
|
export { default as Home } from "lucide-solid/icons/house";
|
||||||
export { default as Keyboard } from "lucide-solid/icons/keyboard";
|
export { default as Keyboard } from "lucide-solid/icons/keyboard";
|
||||||
@@ -12,7 +14,6 @@ export { default as Moon } from "lucide-solid/icons/moon";
|
|||||||
export { default as Plus } from "lucide-solid/icons/plus";
|
export { default as Plus } from "lucide-solid/icons/plus";
|
||||||
export { default as Repeat } from "lucide-solid/icons/repeat";
|
export { default as Repeat } from "lucide-solid/icons/repeat";
|
||||||
export { default as Search } from "lucide-solid/icons/search";
|
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 Settings } from "lucide-solid/icons/settings";
|
||||||
export { default as Shield } from "lucide-solid/icons/shield";
|
export { default as Shield } from "lucide-solid/icons/shield";
|
||||||
export { default as Sun } from "lucide-solid/icons/sun";
|
export { default as Sun } from "lucide-solid/icons/sun";
|
||||||
|
|||||||
Reference in New Issue
Block a user