Compare commits

...

5 Commits

Author SHA1 Message Date
MangoPig
dc5a2495fa Merge branch 'Features/Frontend/CollapsibleShell' 2026-06-17 05:29:14 +01:00
MangoPig
52fc9001c5 Feat: Add collapsible shell 2026-06-17 05:28:48 +01:00
MangoPig
630b3778db Merge branch 'Features/Frontend/Notifications' 2026-06-16 17:00:51 +01:00
MangoPig
248a0b1828 Feat: Add notifications menu 2026-06-16 17:00:51 +01:00
MangoPig
fd429bdcdd Merge branch 'Features/Frontend/ProfileMenu' 2026-06-16 16:39:41 +01:00
21 changed files with 1019 additions and 53 deletions

View File

@@ -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
} }

View File

@@ -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

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>
); );
}; };

View File

@@ -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)

View File

@@ -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,

View File

@@ -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;
}

View 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>
);
};

View File

@@ -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;
}
}

View 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`
: "Youre 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, itll 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}>Youre 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>
);
};

View File

@@ -0,0 +1,5 @@
.root {
position: relative;
display: inline-flex;
align-items: center;
}

View 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>
);
};

View File

@@ -4,6 +4,7 @@ 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 { UserNav } from "./UserNav"; import { UserNav } from "./UserNav";
import styles from "./TopBar.module.scss"; import styles from "./TopBar.module.scss";
@@ -36,6 +37,7 @@ 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} />
<UserNav /> <UserNav />
</div> </div>

View File

@@ -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;

View File

@@ -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>

View File

@@ -9,6 +9,7 @@ import {
Keyboard, Keyboard,
LayoutGrid, LayoutGrid,
LogOut, LogOut,
Plus,
Repeat, Repeat,
Search, Search,
Settings, Settings,
@@ -81,12 +82,26 @@ 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 = { export type ProfileMenuAction = {
id: string; id: string;
label: string; label: string;
@@ -164,11 +179,47 @@ 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; ] 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 = { export const activeUserProfile: ActiveUserProfile = {
name: "Demo Account", name: "Demo Account",
email: "demo@moku.work", email: "demo@moku.work",

View File

@@ -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);
}
} }

View File

@@ -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>

View File

@@ -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";