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