Skip to content
Open
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
86 changes: 49 additions & 37 deletions web/src/pages/TasksPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { TaskFilters } from '../components/Tasks/TaskFilters';
import { TaskCard } from '../components/Tasks/TaskCard';
import { TaskCreateDialog } from '../components/Tasks/TaskCreateDialog';
import { TaskStatusManager } from '../components/Tasks/TaskStatusManager';
import { PageHeader } from '../components/ui/PageHeader';

const SORT_OPTIONS: { value: TaskSort; label: string }[] = [
{ value: 'deadline', label: 'Deadline' },
Expand Down Expand Up @@ -52,12 +53,11 @@ export function TasksPage() {

return (
<div className="h-full flex flex-col">
<div className="border-b border-border-subtle px-6 py-3 flex items-center justify-between bg-bg shrink-0">
<div className="flex items-center gap-4">
<h1 className="text-lg font-semibold">Tasks</h1>
<TaskFilters active={filter} onChange={setFilter} />

<div className="relative ml-2">
<PageHeader
title="Tasks"
filters={<TaskFilters active={filter} onChange={setFilter} />}
search={
<div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-faint" />
<input
type="text"
Expand All @@ -77,37 +77,49 @@ export function TasksPage() {
</button>
)}
</div>
</div>
<div className="flex items-center gap-2">
{!isSearching && (
<label className="flex items-center gap-1.5 text-[12px] text-text-faint">
Sort by
<select
value={sort}
onChange={e => setSort(e.target.value as TaskSort)}
className="px-2 py-1.5 text-[13px] bg-surface-raised border border-border-subtle rounded-lg text-text-secondary focus:outline-none focus:border-accent/50 cursor-pointer"
>
{SORT_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</label>
)}
<button
onClick={() => setShowStatusManager(true)}
title="Manage statuses"
className="flex items-center gap-1.5 px-3 py-1.5 text-[13px] text-text-secondary bg-surface-raised border border-border-subtle hover:border-border rounded-lg cursor-pointer"
>
<SlidersHorizontal size={14} /> Statuses
</button>
<button
onClick={() => setShowCreateDialog(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-[13px] bg-accent hover:bg-accent-hover text-white rounded-lg cursor-pointer"
>
<Plus size={14} /> New Task
</button>
</div>
</div>
}
actions={
<>
{!isSearching && (
// The "Sort by" label costs more than it explains once space is
// tight; the select still names itself via title/aria-label.
<label className="flex items-center gap-1.5 text-[12px] text-text-faint">
<span className="hidden lg:inline">Sort by</span>
<select
value={sort}
onChange={e => setSort(e.target.value as TaskSort)}
title="Sort tasks"
aria-label="Sort tasks"
className="px-2 py-1.5 text-[13px] bg-surface-raised border border-border-subtle rounded-lg text-text-secondary focus:outline-none focus:border-accent/50 cursor-pointer"
>
{SORT_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</label>
)}
<button
onClick={() => setShowStatusManager(true)}
title="Manage statuses"
aria-label="Manage statuses"
className="flex items-center gap-1.5 px-3 py-1.5 text-[13px] text-text-secondary bg-surface-raised border border-border-subtle hover:border-border rounded-lg cursor-pointer whitespace-nowrap"
>
<SlidersHorizontal size={14} /> <span className="hidden sm:inline">Statuses</span>
</button>
<button
onClick={() => setShowCreateDialog(true)}
// The label collapses to an icon on narrow screens, so the
// button carries its name explicitly rather than relying on
// text that is not always rendered.
title="New task"
aria-label="New task"
className="flex items-center gap-1.5 px-3 py-1.5 text-[13px] bg-accent hover:bg-accent-hover text-white rounded-lg cursor-pointer whitespace-nowrap"
>
<Plus size={14} /> <span className="hidden sm:inline">New Task</span>
</button>
</>
}
/>

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