feat(ui): redesign updates center into action inbox

- replace release-note-heavy layout with task-first action inbox

- add persistent local notice progress states (new/seen/done/dismissed)

- introduce actionable notice metadata and related integration mapping

- keep full-page shell fixed with internal scrolling only
This commit is contained in:
Tam Nhu Tran
2026-02-25 20:06:02 +07:00
parent 1698eadc94
commit 78eac7d500
7 changed files with 574 additions and 302 deletions
@@ -0,0 +1,50 @@
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { type NoticeProgressState } from '@/lib/updates-notice-state';
const NOTICE_PROGRESS_META: Record<
NoticeProgressState,
{ label: string; className: string; showDot?: boolean }
> = {
new: {
label: 'Needs Action',
className:
'border-amber-300/70 bg-amber-100/70 text-amber-800 dark:border-amber-500/40 dark:bg-amber-500/15 dark:text-amber-300',
showDot: true,
},
seen: {
label: 'In Review',
className:
'border-blue-300/70 bg-blue-100/70 text-blue-800 dark:border-blue-500/40 dark:bg-blue-500/15 dark:text-blue-300',
},
done: {
label: 'Done',
className:
'border-emerald-300/70 bg-emerald-100/70 text-emerald-800 dark:border-emerald-500/40 dark:bg-emerald-500/15 dark:text-emerald-300',
},
dismissed: {
label: 'Dismissed',
className:
'border-muted-foreground/20 bg-muted text-muted-foreground dark:border-muted-foreground/30',
},
};
export function NoticeProgressBadge({
state,
className,
}: {
state: NoticeProgressState;
className?: string;
}) {
const meta = NOTICE_PROGRESS_META[state];
return (
<Badge
variant="outline"
className={cn('gap-1.5 border text-[10px] font-medium', meta.className, className)}
>
{meta.showDot && <span className="h-1.5 w-1.5 rounded-full bg-current" />}
{meta.label}
</Badge>
);
}
@@ -0,0 +1,157 @@
import { Link } from 'react-router-dom';
import { CalendarClock, CheckCircle2, EyeOff, RotateCcw, Sparkles } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { CopyButton } from '@/components/ui/copy-button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { SupportStatusBadge } from '@/components/updates/support-status-badge';
import { NoticeProgressBadge } from '@/components/updates/notice-progress-badge';
import { UpdatesNoticeActionRow } from '@/components/updates/updates-notice-action-row';
import {
SUPPORT_SCOPE_LABELS,
formatCatalogDate,
type CliSupportEntry,
type SupportNotice,
} from '@/lib/support-updates-catalog';
import { type NoticeProgressState } from '@/lib/updates-notice-state';
type UpdatableNoticeProgress = 'new' | 'seen' | 'done' | 'dismissed';
export function UpdatesDetailsPanel({
notice,
progress,
relatedEntries,
onUpdateProgress,
}: {
notice: SupportNotice | null;
progress: NoticeProgressState | null;
relatedEntries: CliSupportEntry[];
onUpdateProgress: (nextState: UpdatableNoticeProgress) => void;
}) {
if (!notice) {
return (
<div className="flex h-full items-center justify-center rounded-lg border border-dashed bg-muted/20 p-6">
<p className="text-sm text-muted-foreground">No updates available.</p>
</div>
);
}
return (
<div className="flex-1 min-w-0 bg-background grid grid-rows-[auto_minmax(0,1fr)] overflow-hidden">
<div className="border-b bg-background px-4 py-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<h2 className="text-base font-semibold leading-tight">{notice.title}</h2>
<p className="text-sm text-muted-foreground">{notice.summary}</p>
</div>
<div className="flex items-center gap-2">
{progress && <NoticeProgressBadge state={progress} />}
<SupportStatusBadge status={notice.status} />
</div>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<CalendarClock className="h-3.5 w-3.5" />
<span>Published {formatCatalogDate(notice.publishedAt)}</span>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" onClick={() => onUpdateProgress('done')}>
<CheckCircle2 className="h-4 w-4" />
Mark done
</Button>
<Button size="sm" variant="outline" onClick={() => onUpdateProgress('dismissed')}>
<EyeOff className="h-4 w-4" />
Dismiss
</Button>
<Button size="sm" variant="ghost" onClick={() => onUpdateProgress('new')}>
<RotateCcw className="h-4 w-4" />
Reopen
</Button>
</div>
</div>
<div className="min-h-0 p-4">
<div className="grid h-full gap-4 xl:grid-cols-[minmax(0,1.5fr)_minmax(320px,1fr)] overflow-hidden">
<Card className="h-full overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
<CardTitle className="text-base">Do Next</CardTitle>
</div>
<CardDescription>{notice.primaryAction}</CardDescription>
</CardHeader>
<CardContent className="min-h-0">
<ScrollArea className="h-full pr-2">
<div className="space-y-3">
{notice.actions.map((action) => (
<UpdatesNoticeActionRow key={`${notice.id}-${action.id}`} action={action} />
))}
</div>
</ScrollArea>
</CardContent>
</Card>
<div className="grid h-full grid-rows-[minmax(0,1fr)_minmax(0,1fr)] gap-4 overflow-hidden">
<Card className="overflow-hidden">
<CardHeader className="pb-3">
<CardTitle className="text-base">Impacted Integrations</CardTitle>
<CardDescription>Related areas based on update scope and routing.</CardDescription>
</CardHeader>
<CardContent className="min-h-0">
<ScrollArea className="h-full pr-2">
<div className="space-y-2">
{relatedEntries.map((entry) => (
<div key={entry.id} className="rounded-md border bg-muted/20 p-2.5">
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 truncate text-sm font-medium">{entry.name}</p>
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
{SUPPORT_SCOPE_LABELS[entry.scope]}
</Badge>
</div>
<div className="mt-2 flex items-center gap-2">
{entry.routes[0] && (
<Button size="sm" variant="outline" asChild>
<Link to={entry.routes[0].path}>{entry.routes[0].label}</Link>
</Button>
)}
{entry.commands[0] && (
<div className="ml-auto flex min-w-0 items-center gap-1.5">
<code className="truncate rounded bg-background px-1.5 py-0.5 text-[11px]">
{entry.commands[0]}
</code>
<CopyButton value={entry.commands[0]} />
</div>
)}
</div>
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
<Card className="overflow-hidden">
<CardHeader className="pb-3">
<CardTitle className="text-base">Why It Matters</CardTitle>
<CardDescription>
Short context only, no wall-of-text release notes.
</CardDescription>
</CardHeader>
<CardContent className="min-h-0">
<ScrollArea className="h-full pr-2">
<ul className="space-y-2 text-sm text-muted-foreground">
{notice.highlights.map((highlight) => (
<li key={`${notice.id}-${highlight}`}>- {highlight}</li>
))}
</ul>
</ScrollArea>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,45 @@
import { ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatCatalogDate, type SupportNotice } from '@/lib/support-updates-catalog';
import { type NoticeProgressState } from '@/lib/updates-notice-state';
import { NoticeProgressBadge } from './notice-progress-badge';
export function UpdatesInboxItem({
notice,
progress,
selected,
onSelect,
}: {
notice: SupportNotice;
progress: NoticeProgressState;
selected: boolean;
onSelect: () => void;
}) {
return (
<button
type="button"
onClick={onSelect}
className={cn(
'w-full rounded-lg border px-3 py-3 text-left transition-colors',
selected
? 'border-primary/30 bg-primary/10'
: 'border-transparent bg-background/40 hover:border-border hover:bg-muted/70'
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<p className="truncate text-sm font-medium">{notice.title}</p>
<p className="line-clamp-2 text-xs text-muted-foreground">{notice.primaryAction}</p>
</div>
<ChevronRight className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<span className="text-[11px] text-muted-foreground">
{formatCatalogDate(notice.publishedAt)}
</span>
<NoticeProgressBadge state={progress} />
</div>
</button>
);
}
@@ -0,0 +1,38 @@
import { Link } from 'react-router-dom';
import { ArrowUpRight, Terminal } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { CopyButton } from '@/components/ui/copy-button';
import { type SupportNoticeAction } from '@/lib/support-updates-catalog';
export function UpdatesNoticeActionRow({ action }: { action: SupportNoticeAction }) {
const isRouteAction = action.type === 'route' && action.path;
const isCommandAction = action.type === 'command' && action.command;
return (
<div className="rounded-md border bg-muted/20 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-0.5">
<p className="text-sm font-medium">{action.label}</p>
<p className="text-xs text-muted-foreground">{action.description}</p>
</div>
{isRouteAction && (
<Button size="sm" asChild>
<Link to={action.path}>
Open
<ArrowUpRight className="h-3.5 w-3.5" />
</Link>
</Button>
)}
</div>
{isCommandAction && (
<div className="mt-2 flex items-center gap-2 rounded-md border bg-background px-2 py-1.5">
<Terminal className="h-3.5 w-3.5 text-muted-foreground" />
<code className="min-w-0 flex-1 truncate text-[11px]">{action.command}</code>
<CopyButton value={action.command} />
</div>
)}
</div>
);
}
+75
View File
@@ -7,13 +7,26 @@ export interface SupportRouteHint {
path: string; path: string;
} }
export interface SupportNoticeAction {
id: string;
label: string;
description: string;
type: 'route' | 'command';
path?: string;
command?: string;
}
export interface SupportNotice { export interface SupportNotice {
id: string; id: string;
title: string; title: string;
summary: string; summary: string;
primaryAction: string;
publishedAt: string; publishedAt: string;
status: SupportStatus; status: SupportStatus;
scopes: SupportScope[];
entryIds: string[];
highlights: string[]; highlights: string[];
actions: SupportNoticeAction[];
routes: SupportRouteHint[]; routes: SupportRouteHint[];
commands: string[]; commands: string[];
} }
@@ -47,13 +60,48 @@ export const SUPPORT_NOTICES: SupportNotice[] = [
title: 'Factory Droid support is live', title: 'Factory Droid support is live',
summary: summary:
'API Profiles and CLIProxy variants now support Droid as a first-class execution target.', 'API Profiles and CLIProxy variants now support Droid as a first-class execution target.',
primaryAction: 'Set Droid as your default execution target for non-Claude workflows.',
publishedAt: '2026-02-25', publishedAt: '2026-02-25',
status: 'new', status: 'new',
scopes: ['target', 'api-profiles', 'cliproxy'],
entryIds: ['droid-target', 'custom-api-profiles', 'codex-cliproxy', 'agy-cliproxy'],
highlights: [ highlights: [
'Set default target to Droid when creating or editing API Profiles.', 'Set default target to Droid when creating or editing API Profiles.',
'Set default target to Droid for CLIProxy variants, including Codex and Antigravity flows.', 'Set default target to Droid for CLIProxy variants, including Codex and Antigravity flows.',
'Use ccsd alias or --target droid for one-off target overrides.', 'Use ccsd alias or --target droid for one-off target overrides.',
], ],
actions: [
{
id: 'open-api-profiles',
label: 'Set default target in API Profiles',
description:
'Open API Profiles and set Default Target to Droid for profiles you run often.',
type: 'route',
path: '/providers',
},
{
id: 'open-cliproxy',
label: 'Set default target in CLIProxy variants',
description:
'Open CLIProxy variants and set target to Droid for Codex/Antigravity or custom variants.',
type: 'route',
path: '/cliproxy',
},
{
id: 'copy-ccsd-command',
label: 'Run once with Droid alias',
description: 'Use ccsd to force Droid target with your current profile.',
type: 'command',
command: 'ccsd glm',
},
{
id: 'copy-target-override',
label: 'Run once with --target override',
description: 'Keep your default profile but force Droid for a single command.',
type: 'command',
command: 'ccs codex --target droid "your prompt"',
},
],
routes: [ routes: [
{ label: 'API Profiles', path: '/providers' }, { label: 'API Profiles', path: '/providers' },
{ label: 'CLIProxy', path: '/cliproxy' }, { label: 'CLIProxy', path: '/cliproxy' },
@@ -69,13 +117,32 @@ export const SUPPORT_NOTICES: SupportNotice[] = [
title: 'Updates Center added to dashboard navigation', title: 'Updates Center added to dashboard navigation',
summary: summary:
'CCS now has a dedicated updates route so support announcements are visible and reusable.', 'CCS now has a dedicated updates route so support announcements are visible and reusable.',
primaryAction: 'Use this page as your action inbox, then close updates when done.',
publishedAt: '2026-02-25', publishedAt: '2026-02-25',
status: 'new', status: 'new',
scopes: ['target', 'cliproxy', 'api-profiles', 'websearch'],
entryIds: ['droid-target', 'codex-cliproxy', 'custom-api-profiles', 'opencode-websearch'],
highlights: [ highlights: [
'Single data source powers Home spotlight and Updates Center page.', 'Single data source powers Home spotlight and Updates Center page.',
'New support entries can be added without touching multiple pages.', 'New support entries can be added without touching multiple pages.',
'Catalog includes targets, CLIProxy providers, and WebSearch integrations.', 'Catalog includes targets, CLIProxy providers, and WebSearch integrations.',
], ],
actions: [
{
id: 'open-updates-page',
label: 'Review new support updates',
description: 'Work through pending notices and mark them done when configured.',
type: 'route',
path: '/updates',
},
{
id: 'copy-open-dashboard',
label: 'Open dashboard from terminal',
description: 'Re-open config dashboard anytime from CLI.',
type: 'command',
command: 'ccs config',
},
],
routes: [{ label: 'Updates Center', path: '/updates' }], routes: [{ label: 'Updates Center', path: '/updates' }],
commands: ['ccs config'], commands: ['ccs config'],
}, },
@@ -199,6 +266,14 @@ export const CLI_SUPPORT_ENTRIES: CliSupportEntry[] = [
}, },
]; ];
const SUPPORT_ENTRY_LOOKUP = new Map(CLI_SUPPORT_ENTRIES.map((entry) => [entry.id, entry]));
export function getSupportEntriesForNotice(notice: SupportNotice): CliSupportEntry[] {
return notice.entryIds
.map((entryId) => SUPPORT_ENTRY_LOOKUP.get(entryId))
.filter((entry): entry is CliSupportEntry => Boolean(entry));
}
export function getLatestSupportNotice(): SupportNotice | null { export function getLatestSupportNotice(): SupportNotice | null {
if (SUPPORT_NOTICES.length === 0) { if (SUPPORT_NOTICES.length === 0) {
return null; return null;
+63
View File
@@ -0,0 +1,63 @@
import { type SupportNotice, type SupportStatus } from '@/lib/support-updates-catalog';
export type NoticeProgressState = 'new' | 'seen' | 'done' | 'dismissed';
export type NoticeProgressMap = Record<string, NoticeProgressState>;
const NOTICE_PROGRESS_STORAGE_KEY = 'ccs:updates:notice-progress:v1';
export function getDefaultNoticeProgress(status: SupportStatus): NoticeProgressState {
return status === 'new' ? 'new' : 'seen';
}
export function getNoticeProgress(
notice: Pick<SupportNotice, 'id' | 'status'>,
progressMap: NoticeProgressMap
): NoticeProgressState {
return progressMap[notice.id] ?? getDefaultNoticeProgress(notice.status);
}
export function isActionableNoticeState(progress: NoticeProgressState): boolean {
return progress !== 'done' && progress !== 'dismissed';
}
export function readNoticeProgressMap(): NoticeProgressMap {
if (typeof window === 'undefined') {
return {};
}
try {
const rawValue = window.localStorage.getItem(NOTICE_PROGRESS_STORAGE_KEY);
if (!rawValue) {
return {};
}
const parsed = JSON.parse(rawValue);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {};
}
const normalized: NoticeProgressMap = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof key !== 'string') {
continue;
}
if (value === 'new' || value === 'seen' || value === 'done' || value === 'dismissed') {
normalized[key] = value;
}
}
return normalized;
} catch {
return {};
}
}
export function writeNoticeProgressMap(progressMap: NoticeProgressMap): void {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(NOTICE_PROGRESS_STORAGE_KEY, JSON.stringify(progressMap));
}
+146 -302
View File
@@ -1,345 +1,189 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom'; import { Megaphone, Search } from 'lucide-react';
import { CalendarClock, ChevronRight, Filter, Megaphone, Search, Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils'; import { UpdatesDetailsPanel } from '@/components/updates/updates-details-panel';
import { SupportStatusBadge } from '@/components/updates/support-status-badge'; import { UpdatesInboxItem } from '@/components/updates/updates-inbox-item';
import { import {
CLI_SUPPORT_ENTRIES,
SUPPORT_NOTICES, SUPPORT_NOTICES,
SUPPORT_SCOPE_LABELS, getSupportEntriesForNotice,
formatCatalogDate,
type SupportNotice, type SupportNotice,
type SupportScope,
} from '@/lib/support-updates-catalog'; } from '@/lib/support-updates-catalog';
import {
getNoticeProgress,
isActionableNoticeState,
readNoticeProgressMap,
writeNoticeProgressMap,
type NoticeProgressMap,
} from '@/lib/updates-notice-state';
type ScopeFilter = 'all' | SupportScope; type NoticeViewMode = 'inbox' | 'done' | 'all';
const SCOPE_FILTERS: { id: ScopeFilter; label: string }[] = [ const NOTICE_VIEW_MODES: { id: NoticeViewMode; label: string }[] = [
{ id: 'inbox', label: 'Action Required' },
{ id: 'done', label: 'Done' },
{ id: 'all', label: 'All' }, { id: 'all', label: 'All' },
{ id: 'target', label: SUPPORT_SCOPE_LABELS.target },
{ id: 'cliproxy', label: SUPPORT_SCOPE_LABELS.cliproxy },
{ id: 'api-profiles', label: SUPPORT_SCOPE_LABELS['api-profiles'] },
{ id: 'websearch', label: SUPPORT_SCOPE_LABELS.websearch },
]; ];
function NoticeListItem({ function noticeMatchesQuery(notice: SupportNotice, queryValue: string): boolean {
notice, if (!queryValue) {
isSelected, return true;
onSelect, }
}: {
notice: SupportNotice; const haystack = [
isSelected: boolean; notice.title,
onSelect: () => void; notice.summary,
}) { notice.primaryAction,
return ( ...notice.highlights,
<button ...notice.commands,
type="button" ...notice.actions.map(
onClick={onSelect} (action) => `${action.label} ${action.description} ${action.command || ''}`
className={cn( ),
'w-full rounded-lg border px-3 py-2 text-left transition-colors', ...notice.routes.map((route) => route.label),
isSelected ]
? 'border-primary/20 bg-primary/10' .join(' ')
: 'border-transparent hover:border-border hover:bg-muted/70' .toLowerCase();
)}
> return haystack.includes(queryValue);
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{notice.title}</p>
<p className="mt-0.5 text-[11px] text-muted-foreground">
{formatCatalogDate(notice.publishedAt)}
</p>
</div>
<ChevronRight className={cn('mt-0.5 h-4 w-4 shrink-0 text-muted-foreground')} />
</div>
</button>
);
} }
export function UpdatesPage() { export function UpdatesPage() {
const [scope, setScope] = useState<ScopeFilter>('all'); const notices = useMemo(
() => [...SUPPORT_NOTICES].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)),
[]
);
const [viewMode, setViewMode] = useState<NoticeViewMode>('inbox');
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [selectedNoticeId, setSelectedNoticeId] = useState<string | null>( const [progressMap, setProgressMap] = useState<NoticeProgressMap>(() => readNoticeProgressMap());
SUPPORT_NOTICES[0]?.id ?? null const [selectedNoticeId, setSelectedNoticeId] = useState<string | null>(null);
);
const selectedNotice = useMemo( useEffect(() => {
() => SUPPORT_NOTICES.find((notice) => notice.id === selectedNoticeId) ?? SUPPORT_NOTICES[0], writeNoticeProgressMap(progressMap);
[selectedNoticeId] }, [progressMap]);
);
const filteredEntries = useMemo(() => { const visibleNotices = useMemo(() => {
const queryValue = query.trim().toLowerCase(); const queryValue = query.trim().toLowerCase();
return CLI_SUPPORT_ENTRIES.filter((entry) => { return notices.filter((notice) => {
if (scope !== 'all' && entry.scope !== scope) { const progress = getNoticeProgress(notice, progressMap);
return false; const matchesQuery = noticeMatchesQuery(notice, queryValue);
} if (!matchesQuery) return false;
if (viewMode === 'done') return progress === 'done';
if (!queryValue) { if (viewMode === 'inbox') return isActionableNoticeState(progress);
return true; return true;
}
const haystack = [
entry.name,
entry.summary,
entry.notes || '',
...entry.commands,
...entry.routes.map((route) => route.label),
]
.join(' ')
.toLowerCase();
return haystack.includes(queryValue);
}); });
}, [scope, query]); }, [notices, progressMap, query, viewMode]);
const selectedNotice = useMemo(() => {
const selectionPool = viewMode === 'all' ? notices : visibleNotices;
return (
selectionPool.find((notice) => notice.id === selectedNoticeId) ?? selectionPool[0] ?? null
);
}, [notices, selectedNoticeId, viewMode, visibleNotices]);
const handleSelectNotice = (notice: SupportNotice) => {
setSelectedNoticeId(notice.id);
setProgressMap((previous) => {
const progress = getNoticeProgress(notice, previous);
if (progress !== 'new') {
return previous;
}
return { ...previous, [notice.id]: 'seen' };
});
};
const pendingCount = useMemo(
() =>
notices.filter((notice) => isActionableNoticeState(getNoticeProgress(notice, progressMap)))
.length,
[notices, progressMap]
);
const doneCount = useMemo(
() => notices.filter((notice) => getNoticeProgress(notice, progressMap) === 'done').length,
[notices, progressMap]
);
return ( return (
<div className="h-[calc(100vh-100px)] flex overflow-hidden"> <div className="h-[calc(100vh-100px)] flex overflow-hidden">
<div className="w-80 border-r bg-muted/30 flex flex-col overflow-hidden"> <div className="w-80 border-r bg-muted/30 flex flex-col overflow-hidden">
<div className="p-4 border-b bg-background"> <div className="p-4 border-b bg-background space-y-3">
<div className="mb-1 flex items-center gap-2"> <div className="space-y-1">
<Megaphone className="w-5 h-5 text-primary" /> <div className="flex items-center gap-2">
<h1 className="font-semibold">Updates</h1> <Megaphone className="h-5 w-5 text-primary" />
<h1 className="font-semibold">Updates Inbox</h1>
</div>
<p className="text-xs text-muted-foreground">
Focus on actions, then mark updates done or dismissed.
</p>
</div> </div>
<p className="mb-3 text-xs text-muted-foreground">
Product announcements and release notes. <div className="grid grid-cols-2 gap-2 text-xs">
</p> <div className="rounded-md border bg-background px-2 py-1.5">
<p className="text-muted-foreground">Needs Action</p>
<p className="text-base font-semibold">{pendingCount}</p>
</div>
<div className="rounded-md border bg-background px-2 py-1.5">
<p className="text-muted-foreground">Done</p>
<p className="text-base font-semibold">{doneCount}</p>
</div>
</div>
<div className="relative"> <div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input <Input
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
placeholder="Search updates or integrations" placeholder="Search actions or commands"
className="pl-8 h-9" className="h-9 pl-8"
/> />
</div> </div>
<div className="flex flex-wrap gap-1.5">
{NOTICE_VIEW_MODES.map((mode) => (
<Button
key={mode.id}
size="sm"
variant={viewMode === mode.id ? 'default' : 'outline'}
onClick={() => setViewMode(mode.id)}
>
{mode.label}
</Button>
))}
</div>
</div> </div>
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="p-2 space-y-1"> <div className="space-y-2 p-2">
{SUPPORT_NOTICES.map((notice) => ( {visibleNotices.length === 0 ? (
<NoticeListItem <div className="rounded-md border border-dashed p-3 text-xs text-muted-foreground">
key={notice.id} No notices match this view.
notice={notice} </div>
isSelected={selectedNotice?.id === notice.id} ) : (
onSelect={() => setSelectedNoticeId(notice.id)} visibleNotices.map((notice) => (
/> <UpdatesInboxItem
))} key={notice.id}
notice={notice}
progress={getNoticeProgress(notice, progressMap)}
selected={selectedNotice?.id === notice.id}
onSelect={() => handleSelectNotice(notice)}
/>
))
)}
</div> </div>
</ScrollArea> </ScrollArea>
<div className="border-t bg-background p-3 text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<span>
{SUPPORT_NOTICES.length} notice{SUPPORT_NOTICES.length !== 1 ? 's' : ''}
</span>
<span>
{filteredEntries.length} result{filteredEntries.length !== 1 ? 's' : ''}
</span>
</div>
</div>
</div> </div>
<div className="flex-1 min-w-0 bg-background grid grid-rows-[auto_minmax(0,1fr)] overflow-hidden"> <UpdatesDetailsPanel
{selectedNotice && ( notice={selectedNotice}
<div className="border-b bg-background px-4 py-3"> progress={selectedNotice ? getNoticeProgress(selectedNotice, progressMap) : null}
<div className="flex items-start justify-between gap-3"> relatedEntries={selectedNotice ? getSupportEntriesForNotice(selectedNotice) : []}
<div className="min-w-0"> onUpdateProgress={(nextState) => {
<h2 className="text-base font-semibold leading-tight">{selectedNotice.title}</h2> if (!selectedNotice) return;
<p className="mt-1 text-sm text-muted-foreground">{selectedNotice.summary}</p> setProgressMap((previous) => ({ ...previous, [selectedNotice.id]: nextState }));
</div> }}
<SupportStatusBadge status={selectedNotice.status} /> />
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<CalendarClock className="h-3.5 w-3.5" />
<span>Published {formatCatalogDate(selectedNotice.publishedAt)}</span>
</div>
</div>
)}
<div className="min-h-0 p-4">
<div className="grid h-full gap-4 xl:grid-cols-[minmax(0,1.7fr)_minmax(320px,1fr)] overflow-hidden">
<Card className="h-full gap-3 overflow-hidden">
<CardHeader className="gap-2">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
<CardTitle className="text-base">Release Details</CardTitle>
</div>
<CardDescription>
Changelog-first view with impacted integrations and quick commands.
</CardDescription>
</CardHeader>
<CardContent className="flex-1 min-h-0">
<div className="h-full flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Filter className="h-3.5 w-3.5" />
Filter:
</span>
{SCOPE_FILTERS.map((filter) => (
<Button
key={filter.id}
size="sm"
variant={scope === filter.id ? 'default' : 'outline'}
onClick={() => setScope(filter.id)}
>
{filter.label}
</Button>
))}
<span className="ml-auto text-xs text-muted-foreground">
{filteredEntries.length} integration{filteredEntries.length !== 1 ? 's' : ''}
</span>
</div>
<ScrollArea className="flex-1 min-h-0 pr-2">
<div className="space-y-4 pb-1">
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
What Changed
</h3>
<ul className="space-y-1 text-sm text-muted-foreground">
{selectedNotice?.highlights.map((highlight) => (
<li key={`${selectedNotice.id}-${highlight}`}>- {highlight}</li>
))}
</ul>
</section>
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Dashboard Entry Points
</h3>
<div className="flex flex-wrap gap-2">
{selectedNotice?.routes.map((route) => (
<Link
key={`${selectedNotice.id}-${route.path}`}
to={route.path}
className="rounded-md border px-2 py-1 text-xs hover:bg-muted"
>
{route.label}
</Link>
))}
</div>
</section>
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Quick Commands
</h3>
<div className="grid gap-2 lg:grid-cols-2">
{selectedNotice?.commands.map((command) => (
<code
key={`${selectedNotice.id}-${command}`}
className="rounded bg-muted px-2 py-1.5 text-[11px]"
>
{command}
</code>
))}
</div>
</section>
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Impacted Integrations
</h3>
{filteredEntries.length === 0 ? (
<div className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
No integration entries match your current filter.
</div>
) : (
<div className="space-y-2">
{filteredEntries.map((entry) => (
<div key={entry.id} className="rounded-md border bg-muted/20 p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-medium truncate">{entry.name}</p>
<p className="text-xs text-muted-foreground">{entry.summary}</p>
</div>
<SupportStatusBadge
status={entry.status}
className="h-5 px-1.5 text-[10px]"
/>
</div>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<Badge variant="outline" className="text-[10px] h-5 px-1.5">
{SUPPORT_SCOPE_LABELS[entry.scope]}
</Badge>
{entry.routes.map((route) => (
<Link
key={`${entry.id}-${route.path}`}
to={route.path}
className="rounded-md border px-1.5 py-0.5 text-[10px] text-muted-foreground hover:bg-muted"
>
{route.label}
</Link>
))}
</div>
<code className="mt-2 block rounded bg-muted px-2 py-1 text-[11px]">
{entry.commands[0]}
</code>
</div>
))}
</div>
)}
</section>
</div>
</ScrollArea>
</div>
</CardContent>
</Card>
<Card className="h-full gap-3 overflow-hidden">
<CardHeader className="gap-2">
<CardTitle className="text-base">Announcement Timeline</CardTitle>
<CardDescription>Recent notices in chronological order.</CardDescription>
</CardHeader>
<CardContent className="flex-1 min-h-0">
<ScrollArea className="h-full pr-2">
<div className="space-y-2 pb-1">
{SUPPORT_NOTICES.map((notice) => (
<button
key={`timeline-${notice.id}`}
type="button"
onClick={() => setSelectedNoticeId(notice.id)}
className={cn(
'w-full rounded-md border p-3 text-left transition-colors',
selectedNotice?.id === notice.id
? 'border-primary/20 bg-primary/10'
: 'hover:bg-muted/70'
)}
>
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium">{notice.title}</p>
<SupportStatusBadge
status={notice.status}
className="h-5 px-1.5 text-[10px]"
/>
</div>
<p className="mt-1 text-xs text-muted-foreground">{notice.summary}</p>
<p className="mt-2 text-[11px] text-muted-foreground">
{formatCatalogDate(notice.publishedAt)}
</p>
</button>
))}
<div className="rounded-md border border-dashed p-3 text-xs text-muted-foreground">
Update source of truth:{' '}
<code className="rounded bg-muted px-1.5 py-0.5 text-[11px]">
ui/src/lib/support-updates-catalog.ts
</code>
</div>
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
</div>
</div>
</div> </div>
); );
} }