mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 14:19:56 +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;
|
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;
|
||||||
|
|||||||
@@ -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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user