mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 22:21:20 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -7,13 +7,26 @@ export interface SupportRouteHint {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface SupportNoticeAction {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
type: 'route' | 'command';
|
||||
path?: string;
|
||||
command?: string;
|
||||
}
|
||||
|
||||
export interface SupportNotice {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
primaryAction: string;
|
||||
publishedAt: string;
|
||||
status: SupportStatus;
|
||||
scopes: SupportScope[];
|
||||
entryIds: string[];
|
||||
highlights: string[];
|
||||
actions: SupportNoticeAction[];
|
||||
routes: SupportRouteHint[];
|
||||
commands: string[];
|
||||
}
|
||||
@@ -47,13 +60,48 @@ export const SUPPORT_NOTICES: SupportNotice[] = [
|
||||
title: 'Factory Droid support is live',
|
||||
summary:
|
||||
'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',
|
||||
status: 'new',
|
||||
scopes: ['target', 'api-profiles', 'cliproxy'],
|
||||
entryIds: ['droid-target', 'custom-api-profiles', 'codex-cliproxy', 'agy-cliproxy'],
|
||||
highlights: [
|
||||
'Set default target to Droid when creating or editing API Profiles.',
|
||||
'Set default target to Droid for CLIProxy variants, including Codex and Antigravity flows.',
|
||||
'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: [
|
||||
{ label: 'API Profiles', path: '/providers' },
|
||||
{ label: 'CLIProxy', path: '/cliproxy' },
|
||||
@@ -69,13 +117,32 @@ export const SUPPORT_NOTICES: SupportNotice[] = [
|
||||
title: 'Updates Center added to dashboard navigation',
|
||||
summary:
|
||||
'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',
|
||||
status: 'new',
|
||||
scopes: ['target', 'cliproxy', 'api-profiles', 'websearch'],
|
||||
entryIds: ['droid-target', 'codex-cliproxy', 'custom-api-profiles', 'opencode-websearch'],
|
||||
highlights: [
|
||||
'Single data source powers Home spotlight and Updates Center page.',
|
||||
'New support entries can be added without touching multiple pages.',
|
||||
'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' }],
|
||||
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 {
|
||||
if (SUPPORT_NOTICES.length === 0) {
|
||||
return null;
|
||||
|
||||
@@ -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
@@ -1,345 +1,189 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { CalendarClock, ChevronRight, Filter, Megaphone, Search, Sparkles } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Megaphone, Search } from 'lucide-react';
|
||||
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 { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SupportStatusBadge } from '@/components/updates/support-status-badge';
|
||||
import { UpdatesDetailsPanel } from '@/components/updates/updates-details-panel';
|
||||
import { UpdatesInboxItem } from '@/components/updates/updates-inbox-item';
|
||||
import {
|
||||
CLI_SUPPORT_ENTRIES,
|
||||
SUPPORT_NOTICES,
|
||||
SUPPORT_SCOPE_LABELS,
|
||||
formatCatalogDate,
|
||||
getSupportEntriesForNotice,
|
||||
type SupportNotice,
|
||||
type SupportScope,
|
||||
} 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: '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({
|
||||
notice,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
notice: SupportNotice;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
'w-full rounded-lg border px-3 py-2 text-left transition-colors',
|
||||
isSelected
|
||||
? 'border-primary/20 bg-primary/10'
|
||||
: 'border-transparent hover:border-border hover:bg-muted/70'
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
function noticeMatchesQuery(notice: SupportNotice, queryValue: string): boolean {
|
||||
if (!queryValue) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const haystack = [
|
||||
notice.title,
|
||||
notice.summary,
|
||||
notice.primaryAction,
|
||||
...notice.highlights,
|
||||
...notice.commands,
|
||||
...notice.actions.map(
|
||||
(action) => `${action.label} ${action.description} ${action.command || ''}`
|
||||
),
|
||||
...notice.routes.map((route) => route.label),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
return haystack.includes(queryValue);
|
||||
}
|
||||
|
||||
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 [selectedNoticeId, setSelectedNoticeId] = useState<string | null>(
|
||||
SUPPORT_NOTICES[0]?.id ?? null
|
||||
);
|
||||
const [progressMap, setProgressMap] = useState<NoticeProgressMap>(() => readNoticeProgressMap());
|
||||
const [selectedNoticeId, setSelectedNoticeId] = useState<string | null>(null);
|
||||
|
||||
const selectedNotice = useMemo(
|
||||
() => SUPPORT_NOTICES.find((notice) => notice.id === selectedNoticeId) ?? SUPPORT_NOTICES[0],
|
||||
[selectedNoticeId]
|
||||
);
|
||||
useEffect(() => {
|
||||
writeNoticeProgressMap(progressMap);
|
||||
}, [progressMap]);
|
||||
|
||||
const filteredEntries = useMemo(() => {
|
||||
const visibleNotices = useMemo(() => {
|
||||
const queryValue = query.trim().toLowerCase();
|
||||
|
||||
return CLI_SUPPORT_ENTRIES.filter((entry) => {
|
||||
if (scope !== 'all' && entry.scope !== scope) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!queryValue) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const haystack = [
|
||||
entry.name,
|
||||
entry.summary,
|
||||
entry.notes || '',
|
||||
...entry.commands,
|
||||
...entry.routes.map((route) => route.label),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
return haystack.includes(queryValue);
|
||||
return notices.filter((notice) => {
|
||||
const progress = getNoticeProgress(notice, progressMap);
|
||||
const matchesQuery = noticeMatchesQuery(notice, queryValue);
|
||||
if (!matchesQuery) return false;
|
||||
if (viewMode === 'done') return progress === 'done';
|
||||
if (viewMode === 'inbox') return isActionableNoticeState(progress);
|
||||
return true;
|
||||
});
|
||||
}, [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 (
|
||||
<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="p-4 border-b bg-background">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<Megaphone className="w-5 h-5 text-primary" />
|
||||
<h1 className="font-semibold">Updates</h1>
|
||||
<div className="p-4 border-b bg-background space-y-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
Product announcements and release notes.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<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">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search updates or integrations"
|
||||
className="pl-8 h-9"
|
||||
placeholder="Search actions or commands"
|
||||
className="h-9 pl-8"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 space-y-1">
|
||||
{SUPPORT_NOTICES.map((notice) => (
|
||||
<NoticeListItem
|
||||
key={notice.id}
|
||||
notice={notice}
|
||||
isSelected={selectedNotice?.id === notice.id}
|
||||
onSelect={() => setSelectedNoticeId(notice.id)}
|
||||
/>
|
||||
))}
|
||||
<div className="space-y-2 p-2">
|
||||
{visibleNotices.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-3 text-xs text-muted-foreground">
|
||||
No notices match this view.
|
||||
</div>
|
||||
) : (
|
||||
visibleNotices.map((notice) => (
|
||||
<UpdatesInboxItem
|
||||
key={notice.id}
|
||||
notice={notice}
|
||||
progress={getNoticeProgress(notice, progressMap)}
|
||||
selected={selectedNotice?.id === notice.id}
|
||||
onSelect={() => handleSelectNotice(notice)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</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 className="flex-1 min-w-0 bg-background grid grid-rows-[auto_minmax(0,1fr)] overflow-hidden">
|
||||
{selectedNotice && (
|
||||
<div className="border-b bg-background px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-semibold leading-tight">{selectedNotice.title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{selectedNotice.summary}</p>
|
||||
</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>
|
||||
<UpdatesDetailsPanel
|
||||
notice={selectedNotice}
|
||||
progress={selectedNotice ? getNoticeProgress(selectedNotice, progressMap) : null}
|
||||
relatedEntries={selectedNotice ? getSupportEntriesForNotice(selectedNotice) : []}
|
||||
onUpdateProgress={(nextState) => {
|
||||
if (!selectedNotice) return;
|
||||
setProgressMap((previous) => ({ ...previous, [selectedNotice.id]: nextState }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user