Merge branch 'Features/Frontend/Notifications'
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -87,6 +87,14 @@ export type TopBarAction = {
|
|||||||
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;
|
||||||
@@ -166,9 +174,39 @@ export const serverSidebarItems: readonly SidebarItem[] = [
|
|||||||
|
|
||||||
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",
|
||||||
|
|||||||
Reference in New Issue
Block a user