Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions web/src/components/ui/PageHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { ReactNode } from 'react';

/**
* The header strip every list page starts with: icon + title, filter pills,
* an optional search box and optional right-hand actions.
*
* It exists because the pattern had been copy-pasted per page as a single
* non-wrapping flex row. That row reaches ~750px once the pills and the
* search box are in it, so on a 412px viewport the *page itself* scrolled
* sideways and every filter past the third was unreachable.
*
* The single row returns at `lg`, not `md`. The desktop shell also spends 56px
* on the nav rail, so a 768px viewport leaves ~664px of content — less than the
* widest header needs (Notifications: nine pills plus two actions). Even `lg`
* is not enough for that one, which is why the filter strip keeps
* `overflow-x-auto` at every width: a header that outgrows its container
* scrolls inside itself instead of spilling into the controls after it.
*
* Nothing here is reordered with CSS. Below `lg` the actions are rendered in
* the title row and the desktop copy is dropped from the DOM (and vice versa),
* so tab order follows what is on screen at both sizes — a flex `order` swap
* would move them visually while leaving the keyboard to step through every
* filter and the search box first.
*/
export function PageHeader({ icon, title, filters, search, actions }: {
icon?: ReactNode;
title: ReactNode;
/** Filter pills. Laid out by the caller; scrolled horizontally here. */
filters?: ReactNode;
search?: ReactNode;
actions?: ReactNode;
}) {
return (
<div className="border-b border-border-subtle bg-bg shrink-0 px-4 lg:px-6 py-2.5 lg:py-3
flex flex-wrap lg:flex-nowrap items-center gap-x-4 gap-y-2">
{/* Row one below `lg`: the title, with the page's primary buttons pinned
opposite it. With an icon, desktop keeps the 16px icon-to-title gap
and the 24px run-out to the filters the per-page headers used. */}
<div className={`flex items-center gap-2 min-w-0 w-full lg:w-auto lg:flex-none
${icon ? 'lg:gap-4 lg:mr-2' : ''}`}>
{icon}
<h1 className="text-lg font-semibold truncate">{title}</h1>
{actions && (
<div className="ml-auto flex shrink-0 items-center gap-2 lg:hidden">{actions}</div>
)}
</div>

{filters && (
// The negative margins let the strip run to both screen edges, so a
// half-visible pill signals "there is more this way" instead of looking
// like a clipped layout. The width has to grow by the same 2rem the
// margins take back — `w-full` alone would only shift the strip left
// and leave it stopping 32px short of the right edge.
<div className="w-[calc(100%+2rem)] lg:w-auto min-w-0
-mx-4 px-4 lg:mx-0 lg:px-0
overflow-x-auto
[scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div className="flex items-center gap-1 w-max">{filters}</div>
</div>
)}

{search && (
<div className="w-full lg:w-auto">{search}</div>
)}

{/* Desktop copy of the actions: last in the DOM so `ml-auto` pins it to
the right edge without dragging the filters and search along with it. */}
{actions && (
<div className="hidden lg:flex items-center gap-2 shrink-0 lg:ml-auto">
{actions}
</div>
)}
</div>
);
}
121 changes: 62 additions & 59 deletions web/src/pages/NotificationsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Bell, X, CheckCheck, EyeOff, Check, XCircle, Moon, BellOff, Trash2, Plus, RotateCw, Clock } from 'lucide-react';
import { useNotificationStore, type Notification, type Silence } from '../stores/notificationStore';
import { PageHeader } from '../components/ui/PageHeader';

const STATUS_STYLES: Record<string, string> = {
pending: 'bg-yellow-400/10 text-hue-yellow border-yellow-400/20',
Expand Down Expand Up @@ -450,68 +451,70 @@ export function NotificationsPage() {

return (
<div className="h-full flex flex-col">
<div className="border-b border-border-subtle px-6 py-3 flex items-center gap-4 bg-bg shrink-0">
<Bell size={18} className="text-accent" />
<h1 className="text-lg font-semibold">Notifications</h1>

{/* Status filters */}
<div className="flex items-center gap-1 ml-2">
{STATUS_FILTERS.map(f => (
<button
key={f.value}
onClick={() => setFilter(f.value)}
className={`px-3 py-1 text-[12px] rounded-full border cursor-pointer transition-colors
${filter === f.value
? 'bg-accent/15 text-accent border-accent/30'
: 'text-text-dim border-border hover:border-border hover:text-text-muted'
}`}
>
{f.label}
</button>
))}
</div>

{/* Type filters */}
<div className="flex items-center gap-1 ml-1">
{TYPE_FILTERS.map(f => (
<button
key={f.value}
onClick={() => setTypeFilter(f.value)}
className={`px-3 py-1 text-[12px] rounded-full border cursor-pointer transition-colors
${typeFilter === f.value
? 'bg-accent/15 text-accent border-accent/30'
: 'text-text-dim border-border hover:border-border hover:text-text-muted'
}`}
>
{f.label}
</button>
))}
</div>

{/* Silences toggle */}
<button
onClick={() => setShowSilences(v => !v)}
className={`ml-auto flex items-center gap-1.5 px-3 py-1 text-[12px] rounded-lg border cursor-pointer transition-colors
${showSilences
? 'bg-accent/15 text-accent border-accent/30'
: 'border-border text-text-muted hover:text-text-secondary hover:bg-surface-raised'
}`}
>
<BellOff size={13} />
Silences
</button>

{/* Dismiss All */}
{pendingCount > 0 && (
<PageHeader
icon={<Bell size={18} className="text-accent shrink-0" />}
title="Notifications"
filters={
<>
{/* Status filters */}
{STATUS_FILTERS.map(f => (
<button
key={f.value}
onClick={() => setFilter(f.value)}
className={`px-3 py-1 text-[12px] rounded-full border cursor-pointer transition-colors whitespace-nowrap
${filter === f.value
? 'bg-accent/15 text-accent border-accent/30'
: 'text-text-dim border-border hover:border-border hover:text-text-muted'
}`}
>
{f.label}
</button>
))}
{/* Type filters — separated by a rule rather than the old ml-1,
which read as one undifferentiated run of pills once they
shared a scroller. */}
<span className="mx-1 h-4 w-px shrink-0 bg-border-subtle" aria-hidden="true" />
{TYPE_FILTERS.map(f => (
<button
key={f.value}
onClick={() => setTypeFilter(f.value)}
className={`px-3 py-1 text-[12px] rounded-full border cursor-pointer transition-colors whitespace-nowrap
${typeFilter === f.value
? 'bg-accent/15 text-accent border-accent/30'
: 'text-text-dim border-border hover:border-border hover:text-text-muted'
}`}
>
{f.label}
</button>
))}
</>
}
actions={
<>
<button
onClick={dismissAll}
className="flex items-center gap-1.5 px-3 py-1 text-[12px] rounded-lg border border-border text-text-muted hover:text-text-secondary hover:border-border hover:bg-surface-raised cursor-pointer transition-colors"
onClick={() => setShowSilences(v => !v)}
className={`flex items-center gap-1.5 px-3 py-1 text-[12px] rounded-lg border cursor-pointer transition-colors whitespace-nowrap
${showSilences
? 'bg-accent/15 text-accent border-accent/30'
: 'border-border text-text-muted hover:text-text-secondary hover:bg-surface-raised'
}`}
>
<CheckCheck size={13} />
Dismiss All
<BellOff size={13} />
Silences
</button>
)}
</div>
{/* Dismiss All */}
{pendingCount > 0 && (
<button
onClick={dismissAll}
className="flex items-center gap-1.5 px-3 py-1 text-[12px] rounded-lg border border-border text-text-muted hover:text-text-secondary hover:border-border hover:bg-surface-raised cursor-pointer transition-colors whitespace-nowrap"
>
<CheckCheck size={13} />
Dismiss All
</button>
)}
</>
}
/>

<div className="flex-1 overflow-y-auto p-6">
{showSilences && <SilencesPanel />}
Expand Down
37 changes: 18 additions & 19 deletions web/src/pages/PlansPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Lightbulb } from 'lucide-react';
import { usePlanStore, type Plan } from '../stores/planStore';
import { PageHeader } from '../components/ui/PageHeader';

const STATUS_STYLES: Record<string, string> = {
pending: 'bg-yellow-400/10 text-hue-yellow border-yellow-400/20',
Expand Down Expand Up @@ -64,25 +65,23 @@ export function PlansPage() {

return (
<div className="h-full flex flex-col">
<div className="border-b border-border-subtle px-6 py-3 flex items-center gap-4 bg-bg shrink-0">
<Lightbulb size={18} className="text-accent" />
<h1 className="text-lg font-semibold">Plans</h1>
<div className="flex items-center gap-1 ml-2">
{FILTERS.map(f => (
<button
key={f.value}
onClick={() => setFilter(f.value)}
className={`px-3 py-1 text-[12px] rounded-full border cursor-pointer transition-colors
${filter === f.value
? 'bg-accent/15 text-accent border-accent/30'
: 'text-text-dim border-border hover:border-border hover:text-text-muted'
}`}
>
{f.label}
</button>
))}
</div>
</div>
<PageHeader
icon={<Lightbulb size={18} className="text-accent shrink-0" />}
title="Plans"
filters={FILTERS.map(f => (
<button
key={f.value}
onClick={() => setFilter(f.value)}
className={`px-3 py-1 text-[12px] rounded-full border cursor-pointer transition-colors whitespace-nowrap
${filter === f.value
? 'bg-accent/15 text-accent border-accent/30'
: 'text-text-dim border-border hover:border-border hover:text-text-muted'
}`}
>
{f.label}
</button>
))}
/>

<div className="flex-1 overflow-y-auto p-6">
{loading ? (
Expand Down
Loading
Loading