mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-03 14:18:02 +00:00
fix(frontend): tighten bell notification panel toolbar (#929)
* fix(frontend): tighten bell notification panel toolbar Restructure the popover so the segmented filter (All / Unread / Alerts) and the action icons (filter toggle, mark all read, clear all) share a single row inside the 360px panel. The previous layout wrapped the segmented control onto a second row once the type filter was added. Move the unread badge to the right of the title, collapse the node and type dropdowns behind a filter-toggle icon (accent dot signals an active filter), equalize both Select widths, and extract the duplicated className strings into local constants. * docs: refresh bell notification popover screenshot Reflects the reworked toolbar (segmented filter + filter toggle + mark-all-read + clear-all on a single row).
This commit is contained in:
@@ -101,19 +101,21 @@ All dispatched notifications appear in the **notification bell** in the top-righ
|
|||||||
<img src="/images/alerts-notifications/notification-popover.png" alt="Notification popover showing recent alert entries with level badges and timestamps" />
|
<img src="/images/alerts-notifications/notification-popover.png" alt="Notification popover showing recent alert entries with level badges and timestamps" />
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|
||||||
Click the bell to open the notification popover, which shows:
|
Click the bell to open the notification popover. Each entry shows:
|
||||||
|
|
||||||
- **Level badge** for each entry (ERROR in red, WARNING in amber, INFO in default)
|
- **Level badge** (ERROR in red, WARNING in amber, INFO in default)
|
||||||
- **Node name** badge for notifications from remote nodes
|
- **Node name** badge for notifications from remote nodes
|
||||||
- **Timestamp** of when the alert was triggered
|
- **Timestamp** of when the alert was triggered
|
||||||
- **Alert message** describing what was breached
|
- **Alert message** describing what was breached
|
||||||
|
|
||||||
The popover header includes:
|
A toolbar above the list provides filtering and bulk actions:
|
||||||
|
|
||||||
| Action | What it does |
|
| Control | What it does |
|
||||||
|--------|--------------|
|
|---------|--------------|
|
||||||
| **Mark all as read** | Marks all notifications as read (removes the red dot) |
|
| **All / Unread / Alerts** | Switches the list between every notification, unread only, or alert-level only |
|
||||||
| **Clear all** | Deletes all notifications from the list |
|
| **Filter toggle** | Reveals dropdowns to filter by node and by notification type; an accent dot on the icon indicates an active filter |
|
||||||
|
| **Mark all as read** | Marks every notification as read (removes the red dot) |
|
||||||
|
| **Clear all** | Deletes every notification from the list |
|
||||||
|
|
||||||
You can also dismiss individual notifications by hovering over them and clicking the dismiss button.
|
You can also dismiss individual notifications by hovering over them and clicking the dismiss button.
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 52 KiB |
@@ -7,6 +7,8 @@ import {
|
|||||||
AlertOctagon,
|
AlertOctagon,
|
||||||
X,
|
X,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
SlidersHorizontal,
|
||||||
|
CheckCheck,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -30,6 +32,9 @@ type NotifFilter = 'all' | 'unread' | 'alerts';
|
|||||||
type NodeFilter = typeof NODE_FILTER_ALL | number;
|
type NodeFilter = typeof NODE_FILTER_ALL | number;
|
||||||
type CategoryFilter = typeof CATEGORY_FILTER_ALL | NotificationCategory;
|
type CategoryFilter = typeof CATEGORY_FILTER_ALL | NotificationCategory;
|
||||||
|
|
||||||
|
const FILTER_LABEL_CLASS = 'font-mono text-[10px] uppercase tracking-[0.14em]';
|
||||||
|
const FILTER_TRIGGER_CLASS = `h-7 w-[140px] border-card-border bg-card px-2 text-stat-subtitle shadow-none focus:ring-0 ${FILTER_LABEL_CLASS}`;
|
||||||
|
|
||||||
type LevelConfig = {
|
type LevelConfig = {
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
iconClass: string;
|
iconClass: string;
|
||||||
@@ -136,6 +141,10 @@ export function NotificationPanel({
|
|||||||
const [nodeFilter, setNodeFilter] = useState<NodeFilter>(NODE_FILTER_ALL);
|
const [nodeFilter, setNodeFilter] = useState<NodeFilter>(NODE_FILTER_ALL);
|
||||||
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>(CATEGORY_FILTER_ALL);
|
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>(CATEGORY_FILTER_ALL);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
|
||||||
|
const hasActiveFilters =
|
||||||
|
nodeFilter !== NODE_FILTER_ALL || categoryFilter !== CATEGORY_FILTER_ALL;
|
||||||
|
|
||||||
const unreadCount = useMemo(
|
const unreadCount = useMemo(
|
||||||
() => notifications.filter((n) => !n.is_read).length,
|
() => notifications.filter((n) => !n.is_read).length,
|
||||||
@@ -215,25 +224,59 @@ export function NotificationPanel({
|
|||||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-brand/[0.05] via-transparent to-transparent" />
|
<div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-brand/[0.05] via-transparent to-transparent" />
|
||||||
<div className="absolute inset-y-0 left-0 w-[2px] bg-brand/60" />
|
<div className="absolute inset-y-0 left-0 w-[2px] bg-brand/60" />
|
||||||
<div className="relative flex items-center justify-between px-[var(--density-row-x)] py-[var(--density-tile-y)]">
|
<div className="relative flex items-center justify-between px-[var(--density-row-x)] py-[var(--density-tile-y)]">
|
||||||
<div className="flex items-baseline gap-2.5">
|
<span className="font-display text-xl italic leading-none text-stat-value">
|
||||||
<span className="font-display text-xl italic leading-none text-stat-value">
|
Notifications
|
||||||
Notifications
|
</span>
|
||||||
|
{unreadCount > 0 ? (
|
||||||
|
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] tabular-nums text-brand">
|
||||||
|
{unreadCount} unread
|
||||||
</span>
|
</span>
|
||||||
{unreadCount > 0 ? (
|
) : null}
|
||||||
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] tabular-nums text-brand">
|
</div>
|
||||||
{unreadCount} unread
|
</div>
|
||||||
</span>
|
|
||||||
|
{/* Filter segment */}
|
||||||
|
<div className="space-y-2 border-t border-card-border/60 px-[var(--density-row-x)] py-[var(--density-row-y)]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SegmentedControl
|
||||||
|
value={filter}
|
||||||
|
options={filterOptions}
|
||||||
|
onChange={setFilter}
|
||||||
|
ariaLabel="Filter notifications"
|
||||||
|
/>
|
||||||
|
<div className="ml-auto flex items-center gap-0.5">
|
||||||
|
{notifications.length > 0 ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setShowFilters((v) => !v)}
|
||||||
|
className={cn(
|
||||||
|
'relative h-7 w-7 hover:text-stat-value',
|
||||||
|
hasActiveFilters ? 'text-brand' : 'text-stat-subtitle',
|
||||||
|
)}
|
||||||
|
title={showFilters ? 'Hide filters' : 'Show filters'}
|
||||||
|
aria-pressed={showFilters}
|
||||||
|
aria-label="Toggle filters"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||||
|
{hasActiveFilters ? (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-brand"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-0.5">
|
|
||||||
{unreadCount > 0 ? (
|
{unreadCount > 0 ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon"
|
||||||
onClick={onMarkAllRead}
|
onClick={onMarkAllRead}
|
||||||
className="h-7 px-2 font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle hover:text-stat-value"
|
className="h-7 w-7 text-stat-subtitle hover:text-stat-value"
|
||||||
|
title="Mark all read"
|
||||||
|
aria-label="Mark all read"
|
||||||
>
|
>
|
||||||
Mark read
|
<CheckCheck className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{notifications.length > 0 ? (
|
{notifications.length > 0 ? (
|
||||||
@@ -243,68 +286,55 @@ export function NotificationPanel({
|
|||||||
onClick={onClearAll}
|
onClick={onClearAll}
|
||||||
className="h-7 w-7 text-stat-subtitle hover:text-destructive"
|
className="h-7 w-7 text-stat-subtitle hover:text-destructive"
|
||||||
title="Clear all"
|
title="Clear all"
|
||||||
|
aria-label="Clear all notifications"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" strokeWidth={1.5} />
|
<Trash2 className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{showFilters ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
{/* Filter segment */}
|
{showNodeFilter ? (
|
||||||
<div className="flex flex-wrap items-center gap-2 border-t border-card-border/60 px-[var(--density-row-x)] py-[var(--density-row-y)]">
|
<Select
|
||||||
{showNodeFilter ? (
|
value={effectiveNodeFilter === NODE_FILTER_ALL ? NODE_FILTER_ALL : String(effectiveNodeFilter)}
|
||||||
<Select
|
onValueChange={(v) => setNodeFilter(v === NODE_FILTER_ALL ? NODE_FILTER_ALL : Number(v))}
|
||||||
value={effectiveNodeFilter === NODE_FILTER_ALL ? NODE_FILTER_ALL : String(effectiveNodeFilter)}
|
>
|
||||||
onValueChange={(v) => setNodeFilter(v === NODE_FILTER_ALL ? NODE_FILTER_ALL : Number(v))}
|
<SelectTrigger aria-label="Filter by node" className={FILTER_TRIGGER_CLASS}>
|
||||||
>
|
<SelectValue />
|
||||||
<SelectTrigger
|
</SelectTrigger>
|
||||||
aria-label="Filter by node"
|
<SelectContent>
|
||||||
className="h-7 w-[120px] border-card-border bg-card px-2 font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle shadow-none focus:ring-0"
|
<SelectItem value={NODE_FILTER_ALL} className={FILTER_LABEL_CLASS}>
|
||||||
|
All nodes
|
||||||
|
</SelectItem>
|
||||||
|
{nodes.map((n) => (
|
||||||
|
<SelectItem key={n.id} value={String(n.id)} className={FILTER_LABEL_CLASS}>
|
||||||
|
{n.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : null}
|
||||||
|
<Select
|
||||||
|
value={categoryFilter}
|
||||||
|
onValueChange={(v) => setCategoryFilter(v as CategoryFilter)}
|
||||||
>
|
>
|
||||||
<SelectValue />
|
<SelectTrigger aria-label="Filter by category" className={FILTER_TRIGGER_CLASS}>
|
||||||
</SelectTrigger>
|
<SelectValue />
|
||||||
<SelectContent>
|
</SelectTrigger>
|
||||||
<SelectItem value={NODE_FILTER_ALL} className="font-mono text-[10px] uppercase tracking-[0.14em]">
|
<SelectContent>
|
||||||
All nodes
|
<SelectItem value={CATEGORY_FILTER_ALL} className={FILTER_LABEL_CLASS}>
|
||||||
</SelectItem>
|
All types
|
||||||
{nodes.map((n) => (
|
|
||||||
<SelectItem key={n.id} value={String(n.id)} className="font-mono text-[10px] uppercase tracking-[0.14em]">
|
|
||||||
{n.name}
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
{(Object.keys(CATEGORY_LABELS) as NotificationCategory[]).map((cat) => (
|
||||||
</SelectContent>
|
<SelectItem key={cat} value={cat} className={FILTER_LABEL_CLASS}>
|
||||||
</Select>
|
{CATEGORY_LABELS[cat]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<Select
|
|
||||||
value={categoryFilter}
|
|
||||||
onValueChange={(v) => setCategoryFilter(v as CategoryFilter)}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
aria-label="Filter by category"
|
|
||||||
className="h-7 w-[130px] border-card-border bg-card px-2 font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle shadow-none focus:ring-0"
|
|
||||||
>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value={CATEGORY_FILTER_ALL} className="font-mono text-[10px] uppercase tracking-[0.14em]">
|
|
||||||
All types
|
|
||||||
</SelectItem>
|
|
||||||
{(Object.keys(CATEGORY_LABELS) as NotificationCategory[]).map((cat) => (
|
|
||||||
<SelectItem key={cat} value={cat} className="font-mono text-[10px] uppercase tracking-[0.14em]">
|
|
||||||
{CATEGORY_LABELS[cat]}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<div className="ml-auto">
|
|
||||||
<SegmentedControl
|
|
||||||
value={filter}
|
|
||||||
options={filterOptions}
|
|
||||||
onChange={setFilter}
|
|
||||||
ariaLabel="Filter notifications"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stream */}
|
{/* Stream */}
|
||||||
|
|||||||
Reference in New Issue
Block a user