Files
ccs/ui/src/pages/shared.tsx
T
Tam Nhu Tran c3401f0a91 feat(ui): complete dashboard i18n coverage across 162 files
Replace all remaining hardcoded English strings with t() calls across
the CCS dashboard UI. Add zh-CN, vi, ja translations for all new keys.

- 162 files updated (components, hooks, pages, lib utilities)
- 7 domains covered: layout/shared, accounts/auth, CLIProxy,
  compatible CLI (Codex/Cursor/Droid/Copilot), logs/monitoring,
  analytics, and lib utilities
- 4 locales: en, zh-CN, vi, ja with natural (not literal) translations
- Fix duplicate customPresetDialog key shadowing in en/zh-CN
- Fix missing ja nav.logs and providerModelSelector keys
- Fix openrouter-banner count type (string → number)
- Fix add-account-dialog double setLocalError call
- Fix codex-model-providers-card missing useTranslation

Closes #983
2026-04-13 20:52:30 -04:00

781 lines
25 KiB
TypeScript

import { type ReactNode, useMemo, useState } from 'react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useSharedItemContent, useSharedItems, useSharedSummary } from '@/hooks/use-shared';
import { cn } from '@/lib/utils';
import '@/lib/i18n';
import { useTranslation } from 'react-i18next';
import {
AlertCircle,
AlertTriangle,
Bot,
FileText,
FolderOpen,
RefreshCw,
Search,
Sparkles,
} from 'lucide-react';
type TabType = 'commands' | 'skills' | 'agents';
export function SharedPage() {
const { t } = useTranslation();
const [tab, setTab] = useState<TabType>('commands');
const tabLabels: Record<TabType, string> = {
commands: t('sharedPage.commands'),
skills: t('sharedPage.skills'),
agents: t('sharedPage.agents'),
};
const [query, setQuery] = useState('');
const [selectedItemPath, setSelectedItemPath] = useState<string | null>(null);
const {
data: summary,
isError: isSummaryError,
error: summaryError,
refetch: refetchSummary,
} = useSharedSummary();
const { data: items, isLoading, isFetching, isError, error, refetch } = useSharedItems(tab);
const allItems = items?.items ?? [];
const normalizedQuery = query.trim().toLowerCase();
const activeQuery = query.trim();
const filteredItems = useMemo(() => {
const sourceItems = items?.items ?? [];
if (!normalizedQuery) {
return sourceItems;
}
return sourceItems.filter((item) =>
[item.name, item.description, item.path].some((value) =>
value.toLowerCase().includes(normalizedQuery)
)
);
}, [items, normalizedQuery]);
const selectedItem = useMemo(() => {
if (filteredItems.length === 0) {
return null;
}
if (!selectedItemPath) {
return filteredItems[0];
}
return filteredItems.find((item) => item.path === selectedItemPath) ?? filteredItems[0];
}, [filteredItems, selectedItemPath]);
const {
data: selectedItemContent,
isLoading: isContentLoading,
isError: isContentError,
error: contentError,
refetch: refetchContent,
} = useSharedItemContent(tab, selectedItem?.path ?? null);
const tabs: { id: TabType; label: string; icon: typeof FileText; count: number }[] = [
{ id: 'commands', label: tabLabels.commands, icon: FileText, count: summary?.commands ?? 0 },
{ id: 'skills', label: tabLabels.skills, icon: Sparkles, count: summary?.skills ?? 0 },
{ id: 'agents', label: tabLabels.agents, icon: Bot, count: summary?.agents ?? 0 },
];
const totalSharedItems = tabs.reduce((sum, tabOption) => sum + tabOption.count, 0);
const hasNoItems = !isLoading && !isError && allItems.length === 0;
const hasNoMatches = !isLoading && !isError && allItems.length > 0 && filteredItems.length === 0;
// TODO i18n: missing key for "Shared item totals could not be loaded. Listing still works."
const summaryErrorMessage = getSharedErrorMessage(
summaryError,
'Shared item totals could not be loaded. Listing still works.'
);
const itemsErrorMessage = getSharedErrorMessage(
error,
`Unable to fetch shared ${tab}. Please try again.`
);
const contentErrorMessage = getSharedErrorMessage(
contentError,
`Unable to load content for ${selectedItem?.name ?? 'selected item'}.`
);
return (
<div className="h-full overflow-hidden flex flex-col">
<div className="p-6 pb-4 space-y-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div className="space-y-3">
<div>
<h1 className="text-2xl font-bold">{t('sharedPage.title')}</h1>
<p className="text-muted-foreground">{t('sharedPage.subtitle')}</p>
</div>
<Tabs
value={tab}
onValueChange={(nextTab) => {
setTab(nextTab as TabType);
setQuery('');
setSelectedItemPath(null);
}}
>
<TabsList className="h-auto flex-wrap justify-start">
{tabs.map((t) => (
<TabsTrigger key={t.id} value={t.id} className="flex items-center gap-2">
<t.icon className="w-4 h-4" />
<span>{t.label}</span>
<span className="text-xs text-muted-foreground">({t.count})</span>
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
<div className="flex flex-col gap-2 lg:items-end">
<div className="grid w-full gap-2 sm:w-auto sm:min-w-[340px] sm:grid-cols-3">
<HeaderMetricCard label={t('sharedPage.totalShared')} value={totalSharedItems} />
<HeaderMetricCard label={tabLabels[tab]} value={allItems.length} />
<HeaderMetricCard label={t('sharedPage.visible')} value={filteredItems.length} />
</div>
<div className="flex items-center gap-2 text-xs">
<Badge variant="secondary">{t('sharedPage.markdownDetail')}</Badge>
{activeQuery ? (
<Badge variant="outline">
{t('sharedPage.filterPrefix')} {activeQuery}
</Badge>
) : null}
</div>
</div>
</div>
{summary && !summary.symlinkStatus.valid && (
<Alert variant="warning">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>{t('sharedPage.configurationRequired')}</AlertTitle>
<AlertDescription>
{/* TODO i18n: missing key for "Run `ccs sync` to configure." */}
{summary.symlinkStatus.message}. Run `ccs sync` to configure.
</AlertDescription>
</Alert>
)}
{isSummaryError && (
<Alert variant="info">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>{t('sharedPage.countsUnavailable')}</AlertTitle>
<AlertDescription>
<p>{summaryErrorMessage}</p>
<div className="mt-3">
<Button
size="sm"
variant="outline"
onClick={() => {
void refetchSummary();
}}
>
<RefreshCw className="w-4 h-4 mr-2" />
{t('sharedPage.retryCounts')}
</Button>
</div>
</AlertDescription>
</Alert>
)}
</div>
<div className="flex-1 min-h-0 px-6 pb-6">
<div className="h-full rounded-lg border overflow-hidden bg-background">
<div className="grid h-full min-h-0 lg:grid-cols-[320px_minmax(0,1fr)]">
<div className="min-h-0 border-b lg:border-b-0 lg:border-r flex flex-col bg-muted/30">
<div className="p-4 border-b bg-background space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<FolderOpen className="w-4 h-4 text-primary shrink-0" />
<h2 className="font-semibold truncate">{tabLabels[tab]}</h2>
</div>
{!isLoading && !isError && (
<Badge variant="outline" className="text-[10px] h-5">
{filteredItems.length}/{allItems.length}
</Badge>
)}
</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={t('sharedPage.filterPlaceholder', { tab })}
aria-label={t('sharedPage.filterPlaceholder', { tab })}
className="pl-8 h-9"
/>
</div>
{!isLoading && !isError && (
<p className="text-xs text-muted-foreground">
{t('sharedPage.showing', {
visible: filteredItems.length,
total: allItems.length,
tab,
})}
{activeQuery ? t('sharedPage.showingQuery', { query: activeQuery }) : ''}
{isFetching ? t('sharedPage.refreshing') : ''}
</p>
)}
</div>
<ScrollArea className="flex-1 min-h-0">
{isLoading ? (
<div className="p-4 text-sm text-muted-foreground">
{t('sharedPage.loadingShared', { tab })}
</div>
) : isError ? (
<div className="p-4 text-center">
<div className="space-y-3 py-8">
<AlertCircle className="w-10 h-10 mx-auto text-destructive/50" />
<div>
<p className="text-sm font-medium">
{t('sharedPage.failedLoadShared', { tab })}
</p>
<p className="text-xs text-muted-foreground mt-1">{itemsErrorMessage}</p>
</div>
<Button
size="sm"
variant="outline"
onClick={() => {
void refetch();
}}
>
<RefreshCw className="w-4 h-4 mr-2" />
{t('sharedPage.retry')}
</Button>
</div>
</div>
) : hasNoItems ? (
<div className="p-4 text-sm text-muted-foreground">
{t('sharedPage.noSharedFound', { tab })}
</div>
) : hasNoMatches ? (
<div className="p-4 text-sm text-muted-foreground">
{t('sharedPage.noMatch', { tab, query: activeQuery })}
</div>
) : (
<div className="p-2 space-y-1">
{filteredItems.map((item) => (
<button
key={`${item.type}:${item.path}`}
type="button"
onClick={() => setSelectedItemPath(item.path)}
className={cn(
'w-full text-left p-3 rounded-md border transition-colors',
selectedItem?.path === item.path
? 'bg-primary/10 border-primary/30'
: 'bg-background hover:bg-muted border-transparent'
)}
>
<p className="text-sm font-medium truncate">{item.name}</p>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{item.description}
</p>
<p className="text-[11px] text-muted-foreground/90 mt-2 font-mono truncate">
{item.path}
</p>
</button>
))}
</div>
)}
</ScrollArea>
</div>
<div className="min-w-0 min-h-0 flex flex-col bg-muted/20">
{!selectedItem ? (
<div className="min-h-[320px] flex items-center justify-center p-6 text-center text-muted-foreground">
{t('sharedPage.selectOne', { tab: tab.slice(0, -1) })}
</div>
) : (
<>
<div className="px-4 py-3 border-b bg-background">
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold truncate">{selectedItem.name}</h2>
<Badge variant="outline" className="uppercase text-[10px]">
{selectedItem.type}
</Badge>
</div>
</div>
<div className="p-4 space-y-4 min-h-0 flex-1 flex flex-col">
<div className="rounded-md border bg-muted/35 p-3">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<MetadataField
label={t('sharedPage.pathLabel')}
value={selectedItem.path}
mono
/>
{selectedItemContent?.contentPath &&
selectedItemContent.contentPath !== selectedItem.path && (
<MetadataField
label={t('sharedPage.resolvedSource')}
value={selectedItemContent.contentPath}
mono
/>
)}
</div>
</div>
<Card className="min-h-0 flex-1">
<CardContent className="p-0 h-full">
<ScrollArea className="h-full px-5 py-4">
{isContentLoading ? (
<p className="text-sm text-muted-foreground">
{t('sharedPage.loadingMarkdown')}
</p>
) : isContentError ? (
<Alert variant="destructive" className="max-w-2xl">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>{t('sharedPage.failedLoadContent')}</AlertTitle>
<AlertDescription>
<p>{contentErrorMessage}</p>
<div className="mt-3">
<Button
size="sm"
variant="outline"
onClick={() => {
void refetchContent();
}}
>
<RefreshCw className="w-4 h-4 mr-2" />
{t('sharedPage.retryContent')}
</Button>
</div>
</AlertDescription>
</Alert>
) : (
<MarkdownViewer content={selectedItemContent?.content ?? ''} />
)}
</ScrollArea>
</CardContent>
</Card>
</div>
</>
)}
</div>
</div>
</div>
</div>
</div>
);
}
function HeaderMetricCard({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-md border bg-muted/30 px-3 py-2">
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">{label}</p>
<p className="text-lg font-semibold leading-tight mt-1">{value}</p>
</div>
);
}
function MetadataField({
label,
value,
mono = false,
}: {
label: string;
value: string;
mono?: boolean;
}) {
return (
<div className="min-w-0">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</p>
<p className={cn('text-xs mt-1 break-words', mono ? 'font-mono' : 'text-sm')}>{value}</p>
</div>
);
}
function getSharedErrorMessage(error: unknown, fallbackMessage: string): string {
if (!(error instanceof Error)) {
return fallbackMessage;
}
const normalized = error.message.toLowerCase();
if (normalized.includes('failed to fetch') || normalized.includes('network')) {
// TODO i18n: missing key for connection lost message
return 'Connection to dashboard server lost or restarting. Keep `ccs config` running, then retry.';
}
return error.message || fallbackMessage;
}
interface MarkdownBlockHeading {
type: 'heading';
level: number;
text: string;
}
interface MarkdownBlockParagraph {
type: 'paragraph';
text: string;
}
interface MarkdownBlockUnorderedList {
type: 'unordered-list';
items: string[];
}
interface MarkdownBlockOrderedList {
type: 'ordered-list';
items: string[];
}
interface MarkdownBlockCode {
type: 'code';
language: string;
content: string;
}
type MarkdownBlock =
| MarkdownBlockHeading
| MarkdownBlockParagraph
| MarkdownBlockUnorderedList
| MarkdownBlockOrderedList
| MarkdownBlockCode;
interface MarkdownFrontmatterEntry {
key: string;
value: string;
}
interface ParsedMarkdownDocument {
blocks: MarkdownBlock[];
frontmatter: MarkdownFrontmatterEntry[];
}
function MarkdownViewer({ content }: { content: string }) {
const { t } = useTranslation();
const parsedDocument = useMemo(() => parseMarkdownDocument(content), [content]);
if (parsedDocument.blocks.length === 0 && parsedDocument.frontmatter.length === 0) {
return <p className="text-sm text-muted-foreground">{t('sharedPage.noMarkdown')}</p>;
}
return (
<div className="space-y-5">
{parsedDocument.frontmatter.length > 0 ? (
<div className="rounded-md border bg-muted/35 p-3">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{parsedDocument.frontmatter.map((entry) => (
<div key={`${entry.key}:${entry.value}`} className="min-w-0">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{formatFrontmatterLabel(entry.key)}
</p>
<p className="text-xs mt-1 break-words">{entry.value}</p>
</div>
))}
</div>
</div>
) : null}
{parsedDocument.blocks.map((block, index) => {
if (block.type === 'heading') {
const headingClass =
block.level <= 1
? 'text-xl font-semibold'
: block.level === 2
? 'text-lg font-semibold'
: 'text-base font-semibold';
return (
<h3 key={`heading-${index}`} className={headingClass}>
{renderInlineMarkdown(block.text, `heading-${index}`)}
</h3>
);
}
if (block.type === 'paragraph') {
return (
<p key={`paragraph-${index}`} className="text-sm leading-6 whitespace-pre-wrap">
{renderInlineMarkdown(block.text, `paragraph-${index}`)}
</p>
);
}
if (block.type === 'unordered-list') {
return (
<ul key={`ul-${index}`} className="list-disc pl-5 space-y-1 text-sm leading-6">
{block.items.map((item, itemIndex) => (
<li key={`ul-item-${index}-${itemIndex}`}>
{renderInlineMarkdown(item, `ul-item-${index}-${itemIndex}`)}
</li>
))}
</ul>
);
}
if (block.type === 'ordered-list') {
return (
<ol key={`ol-${index}`} className="list-decimal pl-5 space-y-1 text-sm leading-6">
{block.items.map((item, itemIndex) => (
<li key={`ol-item-${index}-${itemIndex}`}>
{renderInlineMarkdown(item, `ol-item-${index}-${itemIndex}`)}
</li>
))}
</ol>
);
}
return (
<div
key={`code-${index}`}
className="rounded-md border bg-muted/60 p-3 font-mono text-xs leading-5 overflow-x-auto"
>
{block.language && (
<div className="text-[10px] uppercase tracking-wide text-muted-foreground mb-2">
{block.language}
</div>
)}
<pre className="whitespace-pre-wrap break-words m-0">{block.content}</pre>
</div>
);
})}
</div>
);
}
function formatFrontmatterLabel(key: string): string {
return key.replace(/[-_]/g, ' ');
}
function renderInlineMarkdown(text: string, keyPrefix: string): ReactNode[] {
const inlinePattern = /(\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*|\[([^\]]+)\]\(([^)]+)\))/g;
const nodes: ReactNode[] = [];
let cursor = 0;
let tokenIndex = 0;
for (const match of text.matchAll(inlinePattern)) {
const fullMatch = match[0];
const offset = match.index ?? 0;
if (offset > cursor) {
nodes.push(text.slice(cursor, offset));
}
if (match[2]) {
nodes.push(
<strong key={`${keyPrefix}-strong-${tokenIndex}`} className="font-semibold">
{match[2]}
</strong>
);
} else if (match[3]) {
nodes.push(
<code
key={`${keyPrefix}-code-${tokenIndex}`}
className="rounded bg-muted px-1 py-0.5 font-mono text-[0.82em]"
>
{match[3]}
</code>
);
} else if (match[4]) {
nodes.push(
<em key={`${keyPrefix}-em-${tokenIndex}`} className="italic">
{match[4]}
</em>
);
} else if (match[5] && match[6]) {
const href = match[6].trim();
if (/^(https?:\/\/|mailto:)/i.test(href)) {
nodes.push(
<a
key={`${keyPrefix}-link-${tokenIndex}`}
href={href}
target="_blank"
rel="noreferrer"
className="text-primary underline underline-offset-2 hover:opacity-90"
>
{match[5]}
</a>
);
} else {
nodes.push(match[5]);
}
} else {
nodes.push(fullMatch);
}
cursor = offset + fullMatch.length;
tokenIndex += 1;
}
if (cursor < text.length) {
nodes.push(text.slice(cursor));
}
if (nodes.length === 0) {
return [text];
}
return nodes;
}
function parseMarkdownDocument(content: string): ParsedMarkdownDocument {
const normalized = content.replace(/\r\n/g, '\n').trim();
if (!normalized) {
return { blocks: [], frontmatter: [] };
}
let markdownBody = normalized;
const frontmatter: MarkdownFrontmatterEntry[] = [];
if (markdownBody.startsWith('---\n')) {
const frontmatterEndIndex = markdownBody.indexOf('\n---\n', 4);
if (frontmatterEndIndex !== -1) {
const rawFrontmatter = markdownBody.slice(4, frontmatterEndIndex).trim();
for (const line of rawFrontmatter.split('\n')) {
const entryMatch = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.+)$/);
if (!entryMatch) {
continue;
}
frontmatter.push({
key: entryMatch[1],
value: entryMatch[2].trim().replace(/^['"]|['"]$/g, ''),
});
}
markdownBody = markdownBody.slice(frontmatterEndIndex + 5).trim();
}
}
return {
blocks: parseMarkdownBlocks(markdownBody),
frontmatter,
};
}
function parseMarkdownBlocks(content: string): MarkdownBlock[] {
if (!content.trim()) {
return [];
}
const lines = content.split('\n');
const blocks: MarkdownBlock[] = [];
let paragraphLines: string[] = [];
let unorderedItems: string[] = [];
let orderedItems: string[] = [];
let codeLanguage = '';
let codeLines: string[] | null = null;
const flushParagraph = () => {
if (paragraphLines.length === 0) {
return;
}
blocks.push({
type: 'paragraph',
text: paragraphLines.join(' '),
});
paragraphLines = [];
};
const flushUnorderedList = () => {
if (unorderedItems.length === 0) {
return;
}
blocks.push({
type: 'unordered-list',
items: unorderedItems,
});
unorderedItems = [];
};
const flushOrderedList = () => {
if (orderedItems.length === 0) {
return;
}
blocks.push({
type: 'ordered-list',
items: orderedItems,
});
orderedItems = [];
};
const flushCodeBlock = () => {
if (!codeLines) {
return;
}
blocks.push({
type: 'code',
language: codeLanguage,
content: codeLines.join('\n'),
});
codeLanguage = '';
codeLines = null;
};
for (const line of lines) {
if (codeLines) {
if (line.trim().startsWith('```')) {
flushCodeBlock();
} else {
codeLines.push(line);
}
continue;
}
if (line.trim().startsWith('```')) {
flushParagraph();
flushUnorderedList();
flushOrderedList();
codeLanguage = line.trim().replace(/^```/, '').trim();
codeLines = [];
continue;
}
if (line.trim().length === 0) {
flushParagraph();
flushUnorderedList();
flushOrderedList();
continue;
}
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
if (headingMatch) {
flushParagraph();
flushUnorderedList();
flushOrderedList();
blocks.push({
type: 'heading',
level: headingMatch[1].length,
text: headingMatch[2].trim(),
});
continue;
}
const unorderedMatch = line.match(/^\s*[-*]\s+(.*)$/);
if (unorderedMatch) {
flushParagraph();
flushOrderedList();
unorderedItems.push(unorderedMatch[1].trim());
continue;
}
const orderedMatch = line.match(/^\s*\d+\.\s+(.*)$/);
if (orderedMatch) {
flushParagraph();
flushUnorderedList();
orderedItems.push(orderedMatch[1].trim());
continue;
}
flushUnorderedList();
flushOrderedList();
paragraphLines.push(line.trim());
}
flushParagraph();
flushUnorderedList();
flushOrderedList();
flushCodeBlock();
return blocks;
}