Compare commits
6 Commits
Features/S
...
Features/F
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fdc5f2d22 | ||
|
|
630b3778db | ||
|
|
248a0b1828 | ||
|
|
fd429bdcdd | ||
|
|
bbebccfcf3 | ||
|
|
fd67af7101 |
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>
|
||||||
|
|
||||||
|
<Show when={!props.collapsed}>
|
||||||
<div class={styles.sectionDivider} aria-hidden="true" />
|
<div class={styles.sectionDivider} aria-hidden="true" />
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Show when={!props.collapsed}>
|
||||||
<div class={styles.items}>
|
<div class={styles.items}>
|
||||||
<For each={organizationItems}>
|
<For each={organizationItems}>
|
||||||
{(item): JSX.Element => <RailEntry item={item} label={item.label} abbreviation={item.abbreviation} />}
|
{(item): JSX.Element => <RailEntry item={item} label={item.label} abbreviation={item.abbreviation} />}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!props.collapsed}>
|
||||||
<div class={styles.bottomCluster}>
|
<div class={styles.bottomCluster}>
|
||||||
<button type="button" class={styles.addButton} aria-label="Create server" title="Create server">
|
<button type="button" class={styles.addButton} aria-label="Create server" title="Create server">
|
||||||
<Plus size={16} strokeWidth={2} />
|
<Plus size={16} strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
{!props.compact ? (
|
||||||
<span class={styles.triggerCopy}>
|
<span class={styles.triggerCopy}>
|
||||||
<span class={styles.eyebrow}>Projects</span>
|
<span class={styles.eyebrow}>Projects</span>
|
||||||
<span class={styles.value}>{selectedProject().name}</span>
|
<span class={styles.value}>{selectedProject().name}</span>
|
||||||
</span>
|
</span>
|
||||||
|
) : null}
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
classList={{
|
classList={{
|
||||||
[styles.triggerIcon]: true,
|
[styles.triggerIcon]: true,
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
transition:
|
||||||
|
background-color 220ms var(--easing-standard),
|
||||||
|
color 220ms var(--easing-standard),
|
||||||
|
transform 180ms var(--easing-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-text) 8%, transparent);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonOpen {
|
||||||
|
background: color-mix(in srgb, var(--color-surface) 92%, transparent);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
background: color-mix(in srgb, var(--color-accent-strong) 12%, transparent);
|
||||||
|
box-shadow: 0 0 0 0.12rem color-mix(in srgb, var(--color-accent-strong) 28%, transparent);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconWrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
@include text-caption;
|
||||||
|
position: absolute;
|
||||||
|
top: -0.45rem;
|
||||||
|
right: -0.7rem;
|
||||||
|
min-width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 0.24rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-surface) 68%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--color-primary-3) 84%, black 16%);
|
||||||
|
color: white;
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
line-height: 1;
|
||||||
|
box-shadow: 0 6px 14px color-mix(in srgb, black 18%, transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
36
Frontend/src/components/shell/TopBar/NotificationsButton.tsx
Normal file
36
Frontend/src/components/shell/TopBar/NotificationsButton.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { JSX } from "solid-js";
|
||||||
|
import { Bell } from "../../../lib/icons";
|
||||||
|
import { unreadNotificationCount } from "../data/shell.data";
|
||||||
|
import styles from "./NotificationsButton.module.scss";
|
||||||
|
|
||||||
|
type NotificationsButtonProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
menuId: string;
|
||||||
|
onToggle: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NotificationsButton = (props: NotificationsButtonProps): JSX.Element => {
|
||||||
|
const hasUnread = unreadNotificationCount > 0;
|
||||||
|
const unreadLabel = hasUnread ? `, ${unreadNotificationCount} unread` : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
classList={{
|
||||||
|
[styles.button]: true,
|
||||||
|
[styles.buttonOpen]: props.isOpen,
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
aria-label={`${props.isOpen ? "Close" : "Open"} notifications${unreadLabel}`}
|
||||||
|
title={`${props.isOpen ? "Close" : "Open"} notifications`}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-controls={props.menuId}
|
||||||
|
aria-expanded={props.isOpen}
|
||||||
|
onClick={props.onToggle}
|
||||||
|
>
|
||||||
|
<span class={styles.iconWrap} aria-hidden="true">
|
||||||
|
<Bell size={18} strokeWidth={2} />
|
||||||
|
{hasUnread ? <span class={styles.badge}>{unreadNotificationCount}</span> : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + var(--space-2));
|
||||||
|
right: 0;
|
||||||
|
width: min(24rem, calc(100vw - (var(--space-4) * 2)));
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--color-border-strong);
|
||||||
|
border-radius: calc(var(--radius-lg) + 0.1rem);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header,
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding-bottom: var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerCopy {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.08rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
@include text-label;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle,
|
||||||
|
.sectionLabel,
|
||||||
|
.itemMeta,
|
||||||
|
.itemTime {
|
||||||
|
@include text-caption;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerAction,
|
||||||
|
.footerAction {
|
||||||
|
@include text-caption;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
transition: color 160ms var(--easing-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerAction:hover,
|
||||||
|
.headerAction:focus-visible,
|
||||||
|
.footerAction:hover,
|
||||||
|
.footerAction:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.listWrap {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
max-height: min(24rem, 60vh);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: var(--space-1);
|
||||||
|
margin-right: calc(var(--space-1) * -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stateCard {
|
||||||
|
display: grid;
|
||||||
|
justify-items: start;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-border) 18%, transparent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: color-mix(in srgb, var(--color-surface) 92%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stateIcon {
|
||||||
|
width: 2.2rem;
|
||||||
|
height: 2.2rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--color-surface-muted) 88%, transparent);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stateTitle {
|
||||||
|
@include text-label;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stateCopy {
|
||||||
|
@include text-caption;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section + .section {
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionLabel {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--color-text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: start;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
background-color 160ms var(--easing-standard),
|
||||||
|
border-color 160ms var(--easing-standard),
|
||||||
|
color 160ms var(--easing-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-surface) 88%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--color-border) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
background: color-mix(in srgb, var(--color-surface) 92%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--color-accent-soft) 52%, transparent);
|
||||||
|
box-shadow: 0 0 0 0.12rem color-mix(in srgb, var(--color-accent-soft) 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemUnread {
|
||||||
|
background: color-mix(in srgb, var(--color-surface) 90%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--color-border) 16%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemMarker,
|
||||||
|
.itemMarkerMuted {
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: color-mix(in srgb, var(--color-primary-2) 78%, white 22%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemMarkerMuted {
|
||||||
|
background: color-mix(in srgb, var(--color-text-subtle) 36%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemBody {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemTitle {
|
||||||
|
@include text-label;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemTime {
|
||||||
|
padding-top: 0.05rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--color-text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include respond-down(mobile) {
|
||||||
|
.menu {
|
||||||
|
width: min(22rem, calc(100vw - (var(--space-3) * 2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemTime {
|
||||||
|
grid-column: 2;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerAction {
|
||||||
|
padding: var(--space-1) 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
112
Frontend/src/components/shell/TopBar/NotificationsMenu.tsx
Normal file
112
Frontend/src/components/shell/TopBar/NotificationsMenu.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { For, Show, type JSX } from "solid-js";
|
||||||
|
import { Bell, Settings } from "../../../lib/icons";
|
||||||
|
import { notificationItems, unreadNotificationCount } from "../data/shell.data";
|
||||||
|
import styles from "./NotificationsMenu.module.scss";
|
||||||
|
|
||||||
|
type NotificationsMenuProps = {
|
||||||
|
id: string;
|
||||||
|
menuRef: (element: HTMLDivElement) => void;
|
||||||
|
onSelect: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NotificationsMenu = (props: NotificationsMenuProps): JSX.Element => {
|
||||||
|
const unreadItems = notificationItems.filter((item) => item.unread);
|
||||||
|
const earlierItems = notificationItems.filter((item) => !item.unread);
|
||||||
|
const hasNotifications = notificationItems.length > 0;
|
||||||
|
const isCaughtUp = unreadItems.length === 0 && hasNotifications;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id={props.id} class={styles.menu} role="menu" aria-label="Notifications" ref={props.menuRef}>
|
||||||
|
<div class={styles.header}>
|
||||||
|
<div class={styles.headerCopy}>
|
||||||
|
<strong class={styles.title}>Notifications</strong>
|
||||||
|
<span class={styles.subtitle}>
|
||||||
|
{unreadNotificationCount > 0
|
||||||
|
? `You have ${unreadNotificationCount} unread`
|
||||||
|
: "You’re all caught up"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={unreadNotificationCount > 0}>
|
||||||
|
<button type="button" role="menuitem" class={styles.headerAction} onClick={props.onSelect}>
|
||||||
|
Mark all read
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles.listWrap}>
|
||||||
|
<Show when={!hasNotifications}>
|
||||||
|
<div class={styles.stateCard}>
|
||||||
|
<span class={styles.stateIcon} aria-hidden="true">
|
||||||
|
<Bell size={18} strokeWidth={2} />
|
||||||
|
</span>
|
||||||
|
<strong class={styles.stateTitle}>No notifications yet</strong>
|
||||||
|
<span class={styles.stateCopy}>When activity starts across your workspace, it’ll show up here.</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={isCaughtUp}>
|
||||||
|
<div class={styles.stateCard}>
|
||||||
|
<span class={styles.stateIcon} aria-hidden="true">
|
||||||
|
<Bell size={18} strokeWidth={2} />
|
||||||
|
</span>
|
||||||
|
<strong class={styles.stateTitle}>You’re all caught up</strong>
|
||||||
|
<span class={styles.stateCopy}>No unread notifications right now. Earlier activity is still available below.</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={unreadItems.length > 0}>
|
||||||
|
<section class={styles.section} aria-label="Unread notifications">
|
||||||
|
<span class={styles.sectionLabel}>Unread</span>
|
||||||
|
<div class={styles.list}>
|
||||||
|
<For each={unreadItems}>
|
||||||
|
{(item): JSX.Element => (
|
||||||
|
<button type="button" role="menuitem" classList={{ [styles.item]: true, [styles.itemUnread]: true }} onClick={props.onSelect}>
|
||||||
|
<span class={styles.itemMarker} aria-hidden="true" />
|
||||||
|
<div class={styles.itemBody}>
|
||||||
|
<span class={styles.itemTitle}>{item.title}</span>
|
||||||
|
<span class={styles.itemMeta}>{item.contextLabel}</span>
|
||||||
|
</div>
|
||||||
|
<span class={styles.itemTime}>{item.timeLabel}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={earlierItems.length > 0}>
|
||||||
|
<section class={styles.section} aria-label="Earlier notifications">
|
||||||
|
<span class={styles.sectionLabel}>Earlier</span>
|
||||||
|
<div class={styles.list}>
|
||||||
|
<For each={earlierItems}>
|
||||||
|
{(item): JSX.Element => (
|
||||||
|
<button type="button" role="menuitem" class={styles.item} onClick={props.onSelect}>
|
||||||
|
<span class={styles.itemMarkerMuted} aria-hidden="true" />
|
||||||
|
<div class={styles.itemBody}>
|
||||||
|
<span class={styles.itemTitle}>{item.title}</span>
|
||||||
|
<span class={styles.itemMeta}>{item.contextLabel}</span>
|
||||||
|
</div>
|
||||||
|
<span class={styles.itemTime}>{item.timeLabel}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles.footer}>
|
||||||
|
<button type="button" role="menuitem" class={styles.footerAction} onClick={props.onSelect}>
|
||||||
|
<Settings size={16} strokeWidth={2} />
|
||||||
|
<span>Notification settings</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" role="menuitem" class={styles.footerAction} onClick={props.onSelect}>
|
||||||
|
<Bell size={16} strokeWidth={2} />
|
||||||
|
<span>View all notifications</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.root {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
56
Frontend/src/components/shell/TopBar/NotificationsNav.tsx
Normal file
56
Frontend/src/components/shell/TopBar/NotificationsNav.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { createEffect, createSignal, createUniqueId, onCleanup, type JSX } from "solid-js";
|
||||||
|
import { NotificationsButton } from "./NotificationsButton";
|
||||||
|
import { NotificationsMenu } from "./NotificationsMenu";
|
||||||
|
import styles from "./NotificationsNav.module.scss";
|
||||||
|
|
||||||
|
export const NotificationsNav = (): JSX.Element => {
|
||||||
|
const [isOpen, setIsOpen] = createSignal(false);
|
||||||
|
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}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
165
Frontend/src/components/shell/TopBar/ProfileMenu.module.scss
Normal file
165
Frontend/src/components/shell/TopBar/ProfileMenu.module.scss
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + var(--space-2));
|
||||||
|
right: 0;
|
||||||
|
width: min(21rem, calc(100vw - (var(--space-4) * 2)));
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--color-border-strong);
|
||||||
|
border-radius: calc(var(--radius-lg) + 0.1rem);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
padding-bottom: var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
align-self: center;
|
||||||
|
margin-right: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarRing {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background:
|
||||||
|
conic-gradient(
|
||||||
|
from 0deg,
|
||||||
|
transparent 0deg 24deg,
|
||||||
|
var(--color-primary-1) 24deg 118deg,
|
||||||
|
transparent 118deg 144deg,
|
||||||
|
var(--color-primary-2) 144deg 238deg,
|
||||||
|
transparent 238deg 264deg,
|
||||||
|
var(--color-primary-3) 264deg 356deg,
|
||||||
|
transparent 356deg 360deg
|
||||||
|
);
|
||||||
|
mask: radial-gradient(circle, transparent 64%, black 67%);
|
||||||
|
-webkit-mask: radial-gradient(circle, transparent 64%, black 67%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarCore {
|
||||||
|
@include text-label;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 78%;
|
||||||
|
height: 78%;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryCopy {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.08rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name,
|
||||||
|
.itemLabel {
|
||||||
|
@include text-label;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.email,
|
||||||
|
.role,
|
||||||
|
.context {
|
||||||
|
@include text-caption;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context {
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
color: var(--color-text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section + .section {
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 2.65rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
background-color 160ms var(--easing-standard),
|
||||||
|
border-color 160ms var(--easing-standard),
|
||||||
|
color 160ms var(--easing-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-surface) 88%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--color-border) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
background: color-mix(in srgb, var(--color-surface) 92%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--color-accent-soft) 52%, transparent);
|
||||||
|
box-shadow: 0 0 0 0.12rem color-mix(in srgb, var(--color-accent-soft) 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemDanger {
|
||||||
|
color: color-mix(in srgb, var(--color-primary-3) 74%, var(--color-text) 26%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemDanger:hover,
|
||||||
|
.itemDanger:focus-visible {
|
||||||
|
background: color-mix(in srgb, var(--color-primary-3) 8%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--color-primary-3) 16%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemIcon {
|
||||||
|
width: 1.9rem;
|
||||||
|
height: 1.9rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: color-mix(in srgb, var(--color-surface) 92%, transparent);
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include respond-down(mobile) {
|
||||||
|
.menu {
|
||||||
|
width: min(20rem, calc(100vw - (var(--space-3) * 2)));
|
||||||
|
}
|
||||||
|
}
|
||||||
61
Frontend/src/components/shell/TopBar/ProfileMenu.tsx
Normal file
61
Frontend/src/components/shell/TopBar/ProfileMenu.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { For, type JSX } from "solid-js";
|
||||||
|
import { User } from "../../../lib/icons";
|
||||||
|
import { activeUserProfile, profileMenuSections } from "../data/shell.data";
|
||||||
|
import styles from "./ProfileMenu.module.scss";
|
||||||
|
|
||||||
|
type ProfileMenuProps = {
|
||||||
|
id: string;
|
||||||
|
menuRef: (element: HTMLDivElement) => void;
|
||||||
|
onSelect: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProfileMenu = (props: ProfileMenuProps): JSX.Element => {
|
||||||
|
return (
|
||||||
|
<div id={props.id} class={styles.menu} role="menu" aria-label="Profile menu" ref={props.menuRef}>
|
||||||
|
<div class={styles.summary}>
|
||||||
|
<div class={styles.avatar} aria-hidden="true">
|
||||||
|
<span class={styles.avatarRing} />
|
||||||
|
<span class={styles.avatarCore}>
|
||||||
|
<User size={16} strokeWidth={2} />
|
||||||
|
</span>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<For each={profileMenuSections}>
|
||||||
|
{(section): JSX.Element => (
|
||||||
|
<div class={styles.section}>
|
||||||
|
<For each={section.items}>
|
||||||
|
{(item): JSX.Element => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
classList={{
|
||||||
|
[styles.item]: true,
|
||||||
|
[styles.itemDanger]: item.tone === "danger",
|
||||||
|
}}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4,8 +4,9 @@ import { For, type JSX } from "solid-js";
|
|||||||
import type { Theme } from "../../../theme/runtime";
|
import type { Theme } from "../../../theme/runtime";
|
||||||
import { topBarActions } from "../data/shell.data";
|
import { topBarActions } from "../data/shell.data";
|
||||||
import { DepartmentSelector } from "../DepartmentSelector/DepartmentSelector";
|
import { DepartmentSelector } from "../DepartmentSelector/DepartmentSelector";
|
||||||
|
import { NotificationsNav } from "./NotificationsNav";
|
||||||
import { ThemeToggle } from "./ThemeToggle";
|
import { ThemeToggle } from "./ThemeToggle";
|
||||||
import { UserNavButton } from "./UserNavButton";
|
import { UserNav } from "./UserNav";
|
||||||
import styles from "./TopBar.module.scss";
|
import styles from "./TopBar.module.scss";
|
||||||
|
|
||||||
type TopBarProps = {
|
type TopBarProps = {
|
||||||
@@ -36,8 +37,9 @@ export const TopBar = (props: TopBarProps): JSX.Element => {
|
|||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<NotificationsNav />
|
||||||
<ThemeToggle theme={props.theme} onToggle={props.onToggleTheme} />
|
<ThemeToggle theme={props.theme} onToggle={props.onToggleTheme} />
|
||||||
<UserNavButton />
|
<UserNav />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
5
Frontend/src/components/shell/TopBar/UserNav.module.scss
Normal file
5
Frontend/src/components/shell/TopBar/UserNav.module.scss
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.root {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
54
Frontend/src/components/shell/TopBar/UserNav.tsx
Normal file
54
Frontend/src/components/shell/TopBar/UserNav.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { createEffect, createSignal, createUniqueId, onCleanup, type JSX } from "solid-js";
|
||||||
|
import { ProfileMenu } from "./ProfileMenu";
|
||||||
|
import { UserNavButton } from "./UserNavButton";
|
||||||
|
import styles from "./UserNav.module.scss";
|
||||||
|
|
||||||
|
export const UserNav = (): JSX.Element => {
|
||||||
|
const [isOpen, setIsOpen] = createSignal(false);
|
||||||
|
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}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -23,7 +23,12 @@
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.userButton:hover .spinContainer {
|
.userButtonOpen {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.userButton:hover .spinContainer,
|
||||||
|
.userButtonOpen .spinContainer {
|
||||||
animation-play-state: running;
|
animation-play-state: running;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,27 @@ import type { JSX } from "solid-js";
|
|||||||
import { User } from "../../../lib/icons";
|
import { User } from "../../../lib/icons";
|
||||||
import styles from "./UserNavButton.module.scss";
|
import styles from "./UserNavButton.module.scss";
|
||||||
|
|
||||||
export const UserNavButton = (): JSX.Element => {
|
type UserNavButtonProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
menuId: string;
|
||||||
|
onToggle: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UserNavButton = (props: UserNavButtonProps): JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<button class={styles.userButton} type="button" aria-label="Open profile" title="Open profile">
|
<button
|
||||||
|
classList={{
|
||||||
|
[styles.userButton]: true,
|
||||||
|
[styles.userButtonOpen]: props.isOpen,
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
aria-label={props.isOpen ? "Close profile menu" : "Open profile menu"}
|
||||||
|
title={props.isOpen ? "Close profile menu" : "Open profile menu"}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-controls={props.menuId}
|
||||||
|
aria-expanded={props.isOpen}
|
||||||
|
onClick={props.onToggle}
|
||||||
|
>
|
||||||
<span class={styles.spinContainer} aria-hidden="true">
|
<span class={styles.spinContainer} aria-hidden="true">
|
||||||
<span class={styles.spinRing} />
|
<span class={styles.spinRing} />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -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,22 +1,65 @@
|
|||||||
// 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(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<div class={styles.headerActions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
classList={{
|
||||||
|
[styles.headerActionButton]: true,
|
||||||
|
[styles.headerCollapseButton]: true,
|
||||||
|
}}
|
||||||
|
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
|
<ProjectSelector
|
||||||
|
compact={props.collapsed}
|
||||||
isOpen={isProjectDrawerOpen()}
|
isOpen={isProjectDrawerOpen()}
|
||||||
onToggle={(): void => {
|
onToggle={(): void => {
|
||||||
setIsProjectDrawerOpen(true);
|
setIsProjectDrawerOpen(true);
|
||||||
@@ -26,6 +69,7 @@ export const WorkspaceSidebar = (): JSX.Element => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
@@ -33,7 +77,9 @@ export const WorkspaceSidebar = (): JSX.Element => {
|
|||||||
[styles.sectionHidden]: isProjectDrawerOpen(),
|
[styles.sectionHidden]: isProjectDrawerOpen(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Show when={!props.collapsed}>
|
||||||
<span class={styles.sectionLabel}>Navigation</span>
|
<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>
|
||||||
|
|||||||
@@ -1,7 +1,21 @@
|
|||||||
// Path: Frontend/src/components/shell/data/shell.data.ts
|
// Path: Frontend/src/components/shell/data/shell.data.ts
|
||||||
|
|
||||||
import type { Component } from "solid-js";
|
import type { Component } from "solid-js";
|
||||||
import { Bell, Folder, Home, LayoutGrid, Search, Settings, User } from "../../../lib/icons";
|
import {
|
||||||
|
Bell,
|
||||||
|
CircleHelp,
|
||||||
|
Folder,
|
||||||
|
Home,
|
||||||
|
Keyboard,
|
||||||
|
LayoutGrid,
|
||||||
|
LogOut,
|
||||||
|
Plus,
|
||||||
|
Repeat,
|
||||||
|
Search,
|
||||||
|
Settings,
|
||||||
|
Shield,
|
||||||
|
User,
|
||||||
|
} from "../../../lib/icons";
|
||||||
|
|
||||||
type ShellIconProps = {
|
type ShellIconProps = {
|
||||||
class?: string;
|
class?: string;
|
||||||
@@ -68,12 +82,45 @@ 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;
|
||||||
icon: ShellIcon;
|
icon: ShellIcon;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NotificationItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
contextLabel: string;
|
||||||
|
timeLabel: string;
|
||||||
|
unread?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProfileMenuAction = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: ShellIcon;
|
||||||
|
tone?: "default" | "danger";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProfileMenuSection = {
|
||||||
|
id: string;
|
||||||
|
items: readonly ProfileMenuAction[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActiveUserProfile = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
roleLabel: string;
|
||||||
|
contextLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
const personalDockActions: readonly ServerDockAction[] = [
|
const personalDockActions: readonly ServerDockAction[] = [
|
||||||
{ id: "account", label: "Account", icon: User },
|
{ id: "account", label: "Account", icon: User },
|
||||||
{ id: "settings", label: "Settings", icon: Settings },
|
{ id: "settings", label: "Settings", icon: Settings },
|
||||||
@@ -132,7 +179,77 @@ 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 },
|
||||||
{ id: "inbox", label: "Inbox", icon: Bell },
|
] as const;
|
||||||
|
|
||||||
|
export const notificationItems: readonly NotificationItem[] = [
|
||||||
|
{
|
||||||
|
id: "comment-design-systems",
|
||||||
|
title: "New comment on Design Systems",
|
||||||
|
contextLabel: "Product • Review thread updated",
|
||||||
|
timeLabel: "2m ago",
|
||||||
|
unread: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sprint-platform",
|
||||||
|
title: "Sprint updated in Platform",
|
||||||
|
contextLabel: "Engineering • Scope changed",
|
||||||
|
timeLabel: "15m ago",
|
||||||
|
unread: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "member-joined",
|
||||||
|
title: "New member joined Operations",
|
||||||
|
contextLabel: "Organization Name • Access granted",
|
||||||
|
timeLabel: "1h ago",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "daily-summary",
|
||||||
|
title: "Daily summary is ready",
|
||||||
|
contextLabel: "General • 8 updates across boards",
|
||||||
|
timeLabel: "Today, 8:00 AM",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const unreadNotificationCount = notificationItems.filter((item) => item.unread).length;
|
||||||
|
|
||||||
|
export const activeUserProfile: ActiveUserProfile = {
|
||||||
|
name: "Demo Account",
|
||||||
|
email: "demo@moku.work",
|
||||||
|
roleLabel: "Founder · Product",
|
||||||
|
contextLabel: "Organization Name • Design Systems",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const profileMenuSections: readonly ProfileMenuSection[] = [
|
||||||
|
{
|
||||||
|
id: "account",
|
||||||
|
items: [
|
||||||
|
{ id: "profile", label: "Profile", icon: User },
|
||||||
|
{ id: "account-settings", label: "Account Settings", icon: Settings },
|
||||||
|
{ id: "notifications", label: "Notifications", icon: Bell },
|
||||||
|
{ id: "security", label: "Security", icon: Shield },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "preferences",
|
||||||
|
items: [
|
||||||
|
{ id: "keyboard-shortcuts", label: "Keyboard Shortcuts", icon: Keyboard },
|
||||||
|
{ id: "theme-preferences", label: "Theme Preferences", icon: Settings },
|
||||||
|
{ id: "help-support", label: "Help & Support", icon: CircleHelp },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "session",
|
||||||
|
items: [
|
||||||
|
{ id: "switch-account", label: "Switch Account", icon: Repeat },
|
||||||
|
{ id: "sign-out", label: "Sign Out", icon: LogOut, tone: "danger" },
|
||||||
|
],
|
||||||
|
},
|
||||||
] as const;
|
] 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>
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
// Path: Frontend/src/lib/icons/index.ts
|
// Path: Frontend/src/lib/icons/index.ts
|
||||||
|
|
||||||
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 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 LayoutGrid } from "lucide-solid/icons/layout-grid";
|
export { default as LayoutGrid } from "lucide-solid/icons/layout-grid";
|
||||||
|
export { default as LogOut } from "lucide-solid/icons/log-out";
|
||||||
export { default as Moon } from "lucide-solid/icons/moon";
|
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 Search } from "lucide-solid/icons/search";
|
export { default as Search } from "lucide-solid/icons/search";
|
||||||
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 Sun } from "lucide-solid/icons/sun";
|
export { default as Sun } from "lucide-solid/icons/sun";
|
||||||
export { default as User } from "lucide-solid/icons/user";
|
export { default as User } from "lucide-solid/icons/user";
|
||||||
|
|||||||
Reference in New Issue
Block a user