Compare commits

..

1 Commits

Author SHA1 Message Date
Simos Mikelatos
1942eebbe4 Update localized README docs 2026-07-03 23:07:46 +00:00
13 changed files with 319 additions and 617 deletions

View File

@@ -126,7 +126,7 @@ CloudCLI UI ist die Open-Source-UI-Schicht, die CloudCLI Cloud antreibt. Du kann
| **REST API** | Ja | Ja | | **REST API** | Ja | Ja |
| **n8n-Node** | Nein | Ja | | **n8n-Node** | Nein | Ja |
| **Team-Sharing** | Nein | Ja | | **Team-Sharing** | Nein | Ja |
| **Plattformkosten** | Kostenlos, Open Source | Ab $7/Monat | | **Plattformkosten** | Kostenlos, Open Source | Ab 7/Monat |
> Beide Optionen verwenden deine eigenen KI-Abonnements (Claude, Cursor usw.) CloudCLI stellt die Umgebung bereit, nicht die KI. > Beide Optionen verwenden deine eigenen KI-Abonnements (Claude, Cursor usw.) CloudCLI stellt die Umgebung bereit, nicht die KI.
@@ -202,7 +202,7 @@ Das bedeutet in der Praxis:
<details> <details>
<summary>Muss ich ein KI-Abonnement separat bezahlen?</summary> <summary>Muss ich ein KI-Abonnement separat bezahlen?</summary>
Ja. CloudCLI stellt die Umgebung bereit, nicht die KI. Du bringst dein eigenes Claude-, Cursor-, Codex- oder Gemini-Abonnement mit. CloudCLI Cloud beginnt bei $7/Monat für die gehostete Umgebung zusätzlich dazu. Ja. CloudCLI stellt die Umgebung bereit, nicht die KI. Du bringst dein eigenes Claude-, Cursor-, Codex- oder Gemini-Abonnement mit. CloudCLI Cloud beginnt bei 7/Monat für die gehostete Umgebung zusätzlich dazu.
</details> </details>

View File

@@ -122,7 +122,7 @@ CloudCLI UI は、CloudCLI Cloud を支えるオープンソースの UI レイ
| **REST API** | はい | はい | | **REST API** | はい | はい |
| **n8n ノード** | いいえ | はい | | **n8n ノード** | いいえ | はい |
| **チーム共有** | いいえ | はい | | **チーム共有** | いいえ | はい |
| **料金プラン** | 無料(オープンソース) | 月 $7〜 | | **料金プラン** | 無料(オープンソース) | 月 7〜 |
> どちらの選択肢でも、AI のサブスクリプションClaude、Cursor など)はご自身のものを使用します — CloudCLI が提供するのは環境であり、AI そのものではありません。 > どちらの選択肢でも、AI のサブスクリプションClaude、Cursor など)はご自身のものを使用します — CloudCLI が提供するのは環境であり、AI そのものではありません。
@@ -194,7 +194,7 @@ CloudCLI UI と CloudCLI Cloud は、Claude Code の横に別物として存在
<details> <details>
<summary>AI のサブスクリプションは別途支払いが必要ですか?</summary> <summary>AI のサブスクリプションは別途支払いが必要ですか?</summary>
はい。CloudCLI は環境を提供するものであり、AI は含まれません。Claude、Cursor、Codex、または Gemini のサブスクリプションはご自身でご用意ください。CloudCLI Cloud のホスティング環境はそれに加えて月額 $7 から提供されます。 はい。CloudCLI は環境を提供するものであり、AI は含まれません。Claude、Cursor、Codex、または Gemini のサブスクリプションはご自身でご用意ください。CloudCLI Cloud のホスティング環境はそれに加えて月額 7 から提供されます。
</details> </details>

View File

@@ -122,7 +122,7 @@ CloudCLI UI는 CloudCLI Cloud를 구동하는 오픈 소스 UI 계층입니다.
| **REST API** | 예 | 예 | | **REST API** | 예 | 예 |
| **n8n 노드** | 아니오 | 예 | | **n8n 노드** | 아니오 | 예 |
| **팀 공유** | 아니오 | 예 | | **팀 공유** | 아니오 | 예 |
| **플랫폼 비용** | 무료, 오픈 소스 | 월 $7부터 | | **플랫폼 비용** | 무료, 오픈 소스 | 월 7부터 |
> 둘 다 자체 AI 구독(Claude, Cursor 등)을 그대로 사용합니다 — CloudCLI는 환경만 제공합니다. > 둘 다 자체 AI 구독(Claude, Cursor 등)을 그대로 사용합니다 — CloudCLI는 환경만 제공합니다.
@@ -195,7 +195,7 @@ CloudCLI UI와 CloudCLI Cloud는 Claude Code를 확장하며 별도로 존재하
<details> <details>
<summary>AI 구독을 별도로 결제해야 하나요?</summary> <summary>AI 구독을 별도로 결제해야 하나요?</summary>
네. CloudCLI는 환경만 제공합니다. Claude, Cursor, Codex, Gemini 구독 비용은 별도로 부과됩니다. CloudCLI Cloud는 관리형 환경을 월 $7부터 제공합니다. 네. CloudCLI는 환경만 제공합니다. Claude, Cursor, Codex, Gemini 구독 비용은 별도로 부과됩니다. CloudCLI Cloud는 관리형 환경을 월 7부터 제공합니다.
</details> </details>

View File

@@ -136,7 +136,7 @@ CloudCLI UI is the open source UI layer that powers CloudCLI Cloud. You can self
| **MCP configuration** | Synced with `~/.claude` | Managed via UI | Managed via UI | | **MCP configuration** | Synced with `~/.claude` | Managed via UI | Managed via UI |
| **REST API** | Yes | Yes | Yes | | **REST API** | Yes | Yes | Yes |
| **Team sharing** | No | No | Yes | | **Team sharing** | No | No | Yes |
| **Platform cost** | Free, open source | Free, open source | Starts at $7/month | | **Platform cost** | Free, open source | Free, open source | Starts at 7/month |
> All options use your own AI subscriptions (Claude, Cursor, etc.) — CloudCLI provides the environment, not the AI. > All options use your own AI subscriptions (Claude, Cursor, etc.) — CloudCLI provides the environment, not the AI.
@@ -212,7 +212,7 @@ Here's what that means in practice:
<details> <details>
<summary>Do I need to pay for an AI subscription separately?</summary> <summary>Do I need to pay for an AI subscription separately?</summary>
Yes. CloudCLI provides the environment, not the AI. You bring your own Claude, Cursor, Codex, or Gemini subscription. CloudCLI Cloud starts at $7/month for the hosted environment on top of that. Yes. CloudCLI provides the environment, not the AI. You bring your own Claude, Cursor, Codex, or Gemini subscription. CloudCLI Cloud starts at 7/month for the hosted environment on top of that.
</details> </details>

View File

@@ -126,7 +126,7 @@ CloudCLI UI — это open source UI-слой, на котором постро
| **REST API** | Да | Да | | **REST API** | Да | Да |
| **n8n node** | Нет | Да | | **n8n node** | Нет | Да |
| **Совместная работа** | Нет | Да | | **Совместная работа** | Нет | Да |
| **Стоимость платформы** | Бесплатно, open source | От $7/месяц | | **Стоимость платформы** | Бесплатно, open source | От 7/месяц |
> В обоих вариантах используются ваши собственные AI-подписки (Claude, Cursor и т.д.) — CloudCLI предоставляет среду, а не сам AI. > В обоих вариантах используются ваши собственные AI-подписки (Claude, Cursor и т.д.) — CloudCLI предоставляет среду, а не сам AI.
@@ -202,7 +202,7 @@ CloudCLI UI и CloudCLI Cloud расширяют Claude Code, а не работ
<details> <details>
<summary>Нужно ли отдельно платить за AI-подписку?</summary> <summary>Нужно ли отдельно платить за AI-подписку?</summary>
Да. CloudCLI предоставляет среду, а не сам AI. Вы приносите свою подписку Claude, Cursor, Codex или Gemini. CloudCLI Cloud начинается от $7/месяц за хостируемую среду поверх этого. Да. CloudCLI предоставляет среду, а не сам AI. Вы приносите свою подписку Claude, Cursor, Codex или Gemini. CloudCLI Cloud начинается от 7/месяц за хостируемую среду поверх этого.
</details> </details>

View File

@@ -125,7 +125,7 @@ CloudCLI UI, CloudCLI Cloud'u güçlendiren açık kaynak arayüz katmanıdır.
| **MCP yapılandırması** | `~/.claude` ile senkron | UI üzerinden yönetilir | UI üzerinden yönetilir | | **MCP yapılandırması** | `~/.claude` ile senkron | UI üzerinden yönetilir | UI üzerinden yönetilir |
| **REST API** | Evet | Evet | Evet | | **REST API** | Evet | Evet | Evet |
| **Ekip paylaşımı** | Hayır | Hayır | Evet | | **Ekip paylaşımı** | Hayır | Hayır | Evet |
| **Platform maliyeti** | Ücretsiz, açık kaynak | Ücretsiz, açık kaynak | Aylık 7 $'dan başlar | | **Platform maliyeti** | Ücretsiz, açık kaynak | Ücretsiz, açık kaynak | Aylık 7 'dan başlar |
> Tüm seçenekler kendi AI aboneliklerini (Claude, Cursor, vb.) kullanır — CloudCLI AI'ı değil, ortamı sağlar. > Tüm seçenekler kendi AI aboneliklerini (Claude, Cursor, vb.) kullanır — CloudCLI AI'ı değil, ortamı sağlar.
@@ -201,7 +201,7 @@ Pratikte bu ne demek:
<details> <details>
<summary>AI aboneliği için ayrıca ödeme yapmam gerekiyor mu?</summary> <summary>AI aboneliği için ayrıca ödeme yapmam gerekiyor mu?</summary>
Evet. CloudCLI AI'yi değil, ortamı sağlar. Kendi Claude, Cursor, Codex veya Gemini aboneliğini getirirsin. CloudCLI Cloud, barındırılan ortam için aylık 7 $'dan başlar — bunun üzerine eklenir. Evet. CloudCLI AI'yi değil, ortamı sağlar. Kendi Claude, Cursor, Codex veya Gemini aboneliğini getirirsin. CloudCLI Cloud, barındırılan ortam için aylık 7 'dan başlar — bunun üzerine eklenir.
</details> </details>

View File

@@ -122,7 +122,7 @@ CloudCLI UI 是 CloudCLI Cloud 的开源 UI 层。你可以在本地机器上自
| **REST API** | 是 | 是 | | **REST API** | 是 | 是 |
| **n8n 节点** | 否 | 是 | | **n8n 节点** | 否 | 是 |
| **团队共享** | 否 | 是 | | **团队共享** | 否 | 是 |
| **平台费用** | 免费开源 | 起价 $7/月 | | **平台费用** | 免费开源 | 起价 7/月 |
> 两种方式都使用你自己的 AI 订阅Claude、Cursor 等)— CloudCLI 提供环境,而非 AI。 > 两种方式都使用你自己的 AI 订阅Claude、Cursor 等)— CloudCLI 提供环境,而非 AI。
@@ -195,7 +195,7 @@ CloudCLI UI 与 CloudCLI Cloud 是对 Claude Code 的扩展,而非旁观 — M
<details> <details>
<summary>需要额外购买 AI 订阅吗?</summary> <summary>需要额外购买 AI 订阅吗?</summary>
需要。CloudCLI 只提供环境。你仍需自行获取 Claude、Cursor、Codex 或 Gemini 订阅。CloudCLI Cloud 从 $7/月起提供托管环境。 需要。CloudCLI 只提供环境。你仍需自行获取 Claude、Cursor、Codex 或 Gemini 订阅。CloudCLI Cloud 从 7/月起提供托管环境。
</details> </details>

View File

@@ -122,7 +122,7 @@ CloudCLI UI 是 CloudCLI Cloud 的開源 UI 層。你可以在本機上自架它
| **REST API** | 是 | 是 | | **REST API** | 是 | 是 |
| **n8n 節點** | 否 | 是 | | **n8n 節點** | 否 | 是 |
| **團隊共享** | 否 | 是 | | **團隊共享** | 否 | 是 |
| **平台費用** | 免費開源 | 起價 $7/月 | | **平台費用** | 免費開源 | 起價 7/月 |
> 兩種方式都使用你自己的 AI 訂閱Claude、Cursor 等)— CloudCLI 提供環境,而非 AI。 > 兩種方式都使用你自己的 AI 訂閱Claude、Cursor 等)— CloudCLI 提供環境,而非 AI。
@@ -195,7 +195,7 @@ CloudCLI UI 與 CloudCLI Cloud 是對 Claude Code 的擴充,而非旁觀 — M
<details> <details>
<summary>需要額外購買 AI 訂閱嗎?</summary> <summary>需要額外購買 AI 訂閱嗎?</summary>
需要。CloudCLI 只提供環境。你仍需自行取得 Claude、Cursor、Codex 或 Gemini 訂閱。CloudCLI Cloud 從 $7/月起提供託管環境。 需要。CloudCLI 只提供環境。你仍需自行取得 Claude、Cursor、Codex 或 Gemini 訂閱。CloudCLI Cloud 從 7/月起提供託管環境。
</details> </details>

View File

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import type { McpProject, McpProvider, McpScope, ProviderMcpServer } from '../types'; import type { McpProject, McpProvider, McpScope, ProviderMcpServer } from '../types';
import { IS_PLATFORM } from '../../../constants/config'; import { IS_PLATFORM } from '../../../constants/config';
import { ActionMenu, Badge, Button } from '../../../shared/view/ui'; import { Badge, Button } from '../../../shared/view/ui';
import { import {
MCP_GLOBAL_SUPPORTED_SCOPES, MCP_GLOBAL_SUPPORTED_SCOPES,
MCP_GLOBAL_SUPPORTED_TRANSPORTS, MCP_GLOBAL_SUPPORTED_TRANSPORTS,
@@ -134,40 +134,33 @@ export default function McpServers({ selectedProvider, currentProjects }: McpSer
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <div className="flex items-center gap-3">
<div className="flex min-w-0 items-start gap-3"> <Server className="h-5 w-5 text-primary" />
<Server className="mt-0.5 h-5 w-5 flex-shrink-0 text-purple-500" /> <h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
<div className="min-w-0 space-y-1">
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
</div>
<ActionMenu
label="Add MCP Server"
icon={Plus}
className="w-full sm:w-auto"
triggerClassName={`w-full sm:w-auto ${MCP_PROVIDER_BUTTON_CLASSES[selectedProvider]}`}
items={[
{
key: 'global',
label: globalButtonLabel,
description: globalAddDescription,
icon: Globe,
onSelect: openGlobalForm,
},
{
key: 'provider',
label: providerButtonLabel,
description: providerAddDescription,
icon: Server,
onSelect: () => openForm(),
},
]}
/>
</div> </div>
<p className="text-sm text-muted-foreground">{description}</p>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={openGlobalForm}
className={MCP_PROVIDER_BUTTON_CLASSES[selectedProvider]}
size="sm"
title={globalAddDescription}
>
<Plus className="mr-2 h-4 w-4" />
{globalButtonLabel}
</Button>
<Button
onClick={() => openForm()}
variant="outline"
size="sm"
title={providerAddDescription}
>
<Plus className="mr-2 h-4 w-4" />
{providerButtonLabel}
</Button>
</div>
<div className="min-h-4"> <div className="min-h-4">
{saveStatus === 'success' && ( {saveStatus === 'success' && (
<span className="animate-in fade-in text-xs text-muted-foreground">{t('saveStatus.success')}</span> <span className="animate-in fade-in text-xs text-muted-foreground">{t('saveStatus.success')}</span>

View File

@@ -7,7 +7,6 @@ import {
FileUp, FileUp,
FolderUp, FolderUp,
Loader2, Loader2,
Plus,
RefreshCw, RefreshCw,
Search, Search,
Upload, Upload,
@@ -19,9 +18,11 @@ import { cn } from '../../../lib/utils';
import { import {
Badge, Badge,
Button, Button,
Dialog, Card,
DialogContent, CardContent,
DialogTitle, CardDescription,
CardHeader,
CardTitle,
Input, Input,
} from '../../../shared/view/ui'; } from '../../../shared/view/ui';
import { useProviderSkills } from '../hooks/useProviderSkills'; import { useProviderSkills } from '../hooks/useProviderSkills';
@@ -214,12 +215,9 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
const [queuedFiles, setQueuedFiles] = useState<QueuedSkillFile[]>([]); const [queuedFiles, setQueuedFiles] = useState<QueuedSkillFile[]>([]);
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [justInstalled, setJustInstalled] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [showInstallPath, setShowInstallPath] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const folderInputRef = useRef<HTMLInputElement | null>(null); const folderInputRef = useRef<HTMLInputElement>(null);
const providerName = PROVIDER_NAMES[selectedProvider]; const providerName = PROVIDER_NAMES[selectedProvider];
const providerPath = selectedProvider === 'opencode' ? null : PROVIDER_SKILL_PATHS[selectedProvider]; const providerPath = selectedProvider === 'opencode' ? null : PROVIDER_SKILL_PATHS[selectedProvider];
@@ -229,19 +227,11 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
setSubmitError(null); setSubmitError(null);
setIsSubmitting(false); setIsSubmitting(false);
setSearchQuery(''); setSearchQuery('');
setIsAddDialogOpen(false);
setShowInstallPath(false);
setJustInstalled(false);
}, [selectedProvider]); }, [selectedProvider]);
const setFolderInputRef = useCallback((node: HTMLInputElement | null) => { useEffect(() => {
folderInputRef.current = node; folderInputRef.current?.setAttribute('webkitdirectory', '');
if (!node) { folderInputRef.current?.setAttribute('directory', '');
return;
}
node.setAttribute('webkitdirectory', '');
node.setAttribute('directory', '');
}, []); }, []);
const filteredSkills = useMemo(() => { const filteredSkills = useMemo(() => {
@@ -364,8 +354,6 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
}))); })));
await addSkills({ entries }); await addSkills({ entries });
setQueuedFiles([]); setQueuedFiles([]);
setJustInstalled(true);
setIsAddDialogOpen(false);
} catch (error) { } catch (error) {
setSubmitError(error instanceof Error ? error.message : 'Failed to import skills'); setSubmitError(error instanceof Error ? error.message : 'Failed to import skills');
} finally { } finally {
@@ -373,381 +361,294 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
} }
}, [addSkills, queuedFiles]); }, [addSkills, queuedFiles]);
const handleAddDialogOpenChange = useCallback((open: boolean) => {
if (open) {
setSubmitError(null);
setShowInstallPath(false);
setJustInstalled(false);
setIsAddDialogOpen(true);
return;
}
setQueuedFiles([]);
setSubmitError(null);
setShowInstallPath(false);
setJustInstalled(false);
setIsAddDialogOpen(false);
}, []);
const uploadPanel = (
<div className="space-y-4">
<div
{...getRootProps()}
className={cn(
'rounded-lg border border-dashed p-4 transition-colors sm:p-5',
isDragActive
? 'border-foreground/40 bg-muted/35'
: 'border-border/70 bg-muted/15 hover:border-foreground/25 hover:bg-muted/25',
)}
>
<input
ref={fileInputRef}
type="file"
accept=".md,text/markdown"
multiple
className="hidden"
onChange={(event) => {
handleDrop(Array.from(event.target.files ?? []));
event.target.value = '';
}}
/>
<input
ref={setFolderInputRef}
type="file"
multiple
className="hidden"
onChange={(event) => {
handleFolderSelection(Array.from(event.target.files ?? []));
event.target.value = '';
}}
/>
<div className="flex flex-col items-center justify-center gap-3 py-4 text-center">
<FileUp className="h-7 w-7 text-muted-foreground" strokeWidth={1.5} />
<div className="space-y-1">
<div className="text-sm font-medium text-foreground">Drop a skill folder or SKILL.md</div>
<div className="text-sm text-muted-foreground">
Folders can include scripts, references, and assets.
</div>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
className="w-full sm:w-auto"
>
<FileUp className="h-4 w-4" />
Choose Files
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => folderInputRef.current?.click()}
className="w-full sm:w-auto"
>
<FolderUp className="h-4 w-4" />
Choose Folder
</Button>
</div>
</div>
</div>
{queuedFiles.length > 0 && (
<div className="space-y-2">
<div className="text-sm font-medium text-foreground">Ready to install</div>
<div className="grid gap-2">
{queuedFiles.map((queuedFile) => (
<div
key={queuedFile.id}
className="flex items-center gap-3 rounded-lg border border-border/70 bg-background/70 px-3 py-2"
>
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-muted/60 text-muted-foreground">
{queuedFile.kind === 'folder' ? <FolderUp className="h-4 w-4" /> : <FileText className="h-4 w-4" />}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">{queuedFile.name}</div>
<div className="text-xs text-muted-foreground">
{queuedFile.kind === 'folder'
? `${queuedFile.files.length} files`
: 'Markdown file'}
{' · '}
{formatFileSize(queuedFile.size)}
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 flex-shrink-0 p-0 text-muted-foreground hover:text-foreground"
aria-label={`Remove ${queuedFile.name}`}
onClick={() => {
setQueuedFiles((previous) => previous.filter((file) => file.id !== queuedFile.id));
}}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
)}
{providerPath && (
<div className="space-y-2">
<button
type="button"
className="text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
onClick={() => setShowInstallPath((current) => !current)}
>
{showInstallPath ? 'Hide install location' : 'Where will this install?'}
</button>
{showInstallPath && (
<div className="rounded-lg border border-border/60 bg-muted/15 p-3">
<code className="block whitespace-normal break-all text-xs text-foreground">{providerPath}</code>
</div>
)}
</div>
)}
</div>
);
return ( return (
<div className="min-w-0 space-y-4 overflow-x-hidden"> <div className="min-w-0 space-y-4 overflow-x-hidden">
<div className="flex min-w-0 items-start gap-3"> <div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-muted/20 text-muted-foreground"> <div className="flex min-w-0 items-start gap-3">
<FileCode2 className="h-4 w-4" strokeWidth={1.7} /> <div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-muted/20 text-muted-foreground">
</div> <FileCode2 className="h-4 w-4" strokeWidth={1.7} />
<div className="min-w-0 space-y-1">
<h3 className="text-lg font-medium text-foreground">{t('tabs.skills', { defaultValue: 'Skills' })}</h3>
<p className="text-sm text-muted-foreground">
Manage {providerName} skills from local files, complete folders, and project-aware locations.
</p>
</div>
</div>
<div className="space-y-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative min-w-0 flex-1 sm:max-w-md">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search skills..."
aria-label="Search skills"
className="h-9 w-full pl-9 pr-9"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery('')}
aria-label="Clear skill search"
className="absolute right-1.5 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div> </div>
<Button <div className="min-w-0 space-y-1">
type="button" <h3 className="text-lg font-medium text-foreground">{t('tabs.skills', { defaultValue: 'Skills' })}</h3>
size="sm" <p className="text-sm text-muted-foreground">
className="w-full sm:w-auto" Install global {providerName} skills from `.md` files or complete skill folders.
onClick={() => handleAddDialogOpenChange(true)} </p>
>
<Plus className="h-4 w-4" />
Add Skill
</Button>
<Button
onClick={() => void refreshSkills({ force: true })}
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={isLoading || isLoadingProjectScopes}
>
<RefreshCw className={cn('h-4 w-4', (isLoading || isLoadingProjectScopes) && 'animate-spin')} />
Refresh
</Button>
</div>
{isLoadingProjectScopes && (
<div className="inline-flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Scanning project skills...
</div> </div>
)} </div>
</div>
<Dialog open={isAddDialogOpen} onOpenChange={handleAddDialogOpenChange}> <Button
<DialogContent onClick={() => void refreshSkills({ force: true })}
wrapperClassName="z-[10000]" variant="outline"
className="flex h-[calc(100vh-2rem)] max-h-[760px] w-[calc(100vw-2rem)] max-w-4xl flex-col overflow-hidden p-0 sm:h-[720px]" size="sm"
className="w-full sm:w-auto"
disabled={isLoading || isLoadingProjectScopes}
> >
<DialogTitle>Add {providerName} Skill</DialogTitle> <RefreshCw className={cn('h-4 w-4', (isLoading || isLoadingProjectScopes) && 'animate-spin')} />
<div className="flex-shrink-0 border-b border-border/60 px-4 py-4"> Refresh
<div className="flex items-start gap-3"> </Button>
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-muted/20 text-muted-foreground"> </div>
<FileUp className="h-4 w-4" />
</div> <Card className="min-w-0 overflow-hidden border-border/70 bg-background shadow-sm">
<div className="min-w-0 flex-1"> <CardHeader className="space-y-3 border-b border-border/60 bg-muted/20">
<div className="text-base font-medium text-foreground">Add {providerName} Skill</div> <div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
<div className="mt-1 text-sm text-muted-foreground"> <div className="text-sm font-medium text-foreground">Upload Skills</div>
Upload a SKILL.md file or a complete skill folder. <div className="min-w-0 rounded-2xl border border-border/60 bg-background/70 p-3">
</div> <div className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Install Path</div>
</div> <code className="mt-1 block whitespace-normal break-all text-xs text-foreground">{providerPath}</code>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
aria-label="Close add skill dialog"
disabled={isSubmitting}
onClick={() => handleAddDialogOpenChange(false)}
>
<X className="h-4 w-4" />
</Button>
</div> </div>
</div> </div>
</CardHeader>
<div className="min-h-0 flex-1 overflow-y-auto p-4"> <CardContent className="space-y-4 p-4">
{uploadPanel} <div className="space-y-4">
</div> <div
{...getRootProps()}
<div className="flex flex-shrink-0 flex-col gap-3 border-t border-border/60 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"> className={cn(
<div className="min-w-0 flex-1"> 'rounded-3xl border border-dashed p-4 transition-colors sm:p-5',
{(submitError || loadError || (justInstalled && saveStatus === 'success')) ? ( isDragActive
<div className={cn( ? 'border-foreground/40 bg-muted/35'
'max-h-24 overflow-y-auto whitespace-pre-wrap rounded-lg border px-3 py-2 text-sm', : 'border-border/70 bg-muted/15 hover:border-foreground/25 hover:bg-muted/25',
submitError || loadError
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200'
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
)}>
{submitError || loadError || 'Skills saved successfully.'}
</div>
) : (
<span className="text-xs text-muted-foreground">
Folder uploads keep the selected folder name; standalone files use the `name` in `SKILL.md`.
</span>
)} )}
>
<input
ref={fileInputRef}
type="file"
accept=".md,text/markdown"
multiple
className="hidden"
onChange={(event) => {
handleDrop(Array.from(event.target.files ?? []));
event.target.value = '';
}}
/>
<input
ref={folderInputRef}
type="file"
multiple
className="hidden"
onChange={(event) => {
handleFolderSelection(Array.from(event.target.files ?? []));
event.target.value = '';
}}
/>
<div className="flex flex-col items-center justify-center gap-3 py-4 text-center sm:py-6">
<FileUp className="h-7 w-7 text-muted-foreground" strokeWidth={1.5} />
<div className="space-y-1">
<div className="text-sm font-medium text-foreground">Drop `.md` files or skill folders here</div>
<div className="text-sm text-muted-foreground">
Upload standalone definitions or choose a full folder to include its scripts, references, and assets.
</div>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
className="w-full sm:w-auto"
>
<FileUp className="h-4 w-4" />
Choose Files
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => folderInputRef.current?.click()}
className="w-full sm:w-auto"
>
<FolderUp className="h-4 w-4" />
Choose Folder
</Button>
</div>
</div>
</div> </div>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center">
{queuedFiles.length > 0 && (
<div className="space-y-2">
<div className="text-sm font-medium text-foreground">Queued Files</div>
<div className="grid gap-2">
{queuedFiles.map((queuedFile) => (
<div
key={queuedFile.id}
className="flex flex-col gap-3 rounded-2xl border border-border/70 bg-background/70 px-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:py-2"
>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-foreground">{queuedFile.name}</div>
<div className="text-xs text-muted-foreground">
{queuedFile.kind === 'folder'
? `${queuedFile.files.length} files`
: 'Markdown file'}
{' · '}
{formatFileSize(queuedFile.size)}
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="w-full sm:w-auto"
onClick={() => {
setQueuedFiles((previous) => previous.filter((file) => file.id !== queuedFile.id));
}}
>
Remove
</Button>
</div>
))}
</div>
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<Button <Button
type="button" type="button"
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={isSubmitting}
onClick={() => handleAddDialogOpenChange(false)}
>
Cancel
</Button>
<Button
type="button"
size="sm"
className="w-full sm:w-auto"
onClick={() => void handleUploadInstall()} onClick={() => void handleUploadInstall()}
disabled={isSubmitting || queuedFiles.length === 0} disabled={isSubmitting}
className="w-full sm:w-auto"
> >
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />} {isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
Install {queuedFiles.length > 0 ? `${queuedFiles.length} Skill${queuedFiles.length === 1 ? '' : 's'}` : 'Skill'} Install {queuedFiles.length > 0 ? `${queuedFiles.length} Skill${queuedFiles.length === 1 ? '' : 's'}` : 'Skills'}
</Button> </Button>
</div> <span className="text-xs text-muted-foreground">
</div> Folder uploads keep the selected folder name; standalone files use the `name` in `SKILL.md`.
</DialogContent>
</Dialog>
{!isAddDialogOpen && (submitError || loadError) && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200">
{submitError || loadError}
</div>
)}
{justInstalled && saveStatus === 'success' && !isAddDialogOpen && (
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
<CheckCircle2 className="h-4 w-4" />
Skills saved successfully.
</div>
)}
<div className="space-y-5">
{isLoading && skills.length === 0 && (
<div className="flex min-h-[180px] items-center justify-center text-sm text-muted-foreground">
Loading {providerName} skills
</div>
)}
{!isLoading && skills.length === 0 && (
<div className="rounded-lg border border-dashed border-border/70 bg-muted/15 px-4 py-10 text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-lg border border-border/60 bg-background/80 text-muted-foreground">
<FileText className="h-6 w-6" />
</div>
<div className="mt-4 text-sm font-medium text-foreground">No skills discovered yet</div>
<div className="mt-1 text-sm text-muted-foreground">
Add a global skill above or create project-specific skill folders in your workspace.
</div>
</div>
)}
{!isLoading && skills.length > 0 && filteredSkills.length === 0 && (
<div className="rounded-lg border border-dashed border-border/70 bg-muted/15 px-4 py-10 text-center">
<Search className="mx-auto h-6 w-6 text-muted-foreground" />
<div className="mt-3 text-sm font-medium text-foreground">No matching skills</div>
<div className="mt-1 text-sm text-muted-foreground">
Try a different command, name, scope, project, or source path.
</div>
</div>
)}
{groupedSkills.map((group) => (
<section key={group.scope} className="min-w-0 space-y-3">
<div className="flex items-center gap-2">
<Badge variant="outline" className={cn('rounded-full px-2.5 py-1 text-xs', SCOPE_BADGE_CLASSES[group.scope])}>
{SCOPE_LABELS[group.scope]}
</Badge>
<span className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{group.skills.length} skill{group.skills.length === 1 ? '' : 's'}
</span> </span>
</div> </div>
</div>
<div className="grid min-w-0 gap-3 lg:grid-cols-2"> {(submitError || loadError) && (
{group.skills.map((skill) => ( <div className="rounded-2xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200">
<div {submitError || loadError}
key={`${skill.command}:${skill.sourcePath}:${skill.projectPath || 'global'}`}
className="min-w-0 rounded-lg border border-border bg-card/50 p-4"
>
<div className="min-w-0 space-y-1">
<div className="break-all font-mono text-sm font-semibold text-foreground">{skill.command}</div>
<div className="text-sm text-muted-foreground">{skill.name}</div>
</div>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
{skill.description || 'No description provided in the skill front matter.'}
</p>
<div className="mt-4 flex flex-wrap items-center gap-2">
{skill.pluginName && (
<Badge variant="outline" className="rounded-full bg-background/70">
Plugin: {skill.pluginName}
</Badge>
)}
{skill.projectDisplayName && (
<Badge variant="outline" className="rounded-full bg-background/70">
Project: {skill.projectDisplayName}
</Badge>
)}
</div>
<div className="mt-4 min-w-0 rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
<div className="text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">Source</div>
<code className="mt-1 block whitespace-normal break-all text-xs text-foreground">{skill.sourcePath}</code>
</div>
</div>
))}
</div> </div>
</section> )}
))}
</div> {saveStatus === 'success' && (
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
<CheckCircle2 className="h-4 w-4" />
Skills saved successfully.
</div>
)}
</CardContent>
</Card>
<Card className="min-w-0 border-border/70 bg-background/80 shadow-sm">
<CardHeader className="border-b border-border/60">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0">
<CardTitle>Visible Skills</CardTitle>
<CardDescription>
The list below comes from the provider skill discovery API and includes global and project-aware locations.
</CardDescription>
</div>
<div className="relative w-full lg:w-72">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search skills..."
aria-label="Search visible skills"
className="h-9 w-full pl-9 pr-9"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery('')}
aria-label="Clear skill search"
className="absolute right-1.5 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{isLoadingProjectScopes && (
<div className="inline-flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Scanning project skills
</div>
)}
</div>
</CardHeader>
<CardContent className="space-y-5 p-4">
{isLoading && skills.length === 0 && (
<div className="flex min-h-[180px] items-center justify-center text-sm text-muted-foreground">
Loading {providerName} skills
</div>
)}
{!isLoading && skills.length === 0 && (
<div className="rounded-3xl border border-dashed border-border/70 bg-muted/15 px-4 py-10 text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl border border-border/60 bg-background/80 text-muted-foreground">
<FileText className="h-6 w-6" />
</div>
<div className="mt-4 text-sm font-medium text-foreground">No skills discovered yet</div>
<div className="mt-1 text-sm text-muted-foreground">
Add a global skill above or create project-specific skill folders in your workspace.
</div>
</div>
)}
{!isLoading && skills.length > 0 && filteredSkills.length === 0 && (
<div className="rounded-3xl border border-dashed border-border/70 bg-muted/15 px-4 py-10 text-center">
<Search className="mx-auto h-6 w-6 text-muted-foreground" />
<div className="mt-3 text-sm font-medium text-foreground">No matching skills</div>
<div className="mt-1 text-sm text-muted-foreground">
Try a different command, name, scope, project, or source path.
</div>
</div>
)}
{groupedSkills.map((group) => (
<section key={group.scope} className="min-w-0 space-y-3">
<div className="flex items-center gap-2">
<Badge variant="outline" className={cn('rounded-full px-2.5 py-1 text-xs', SCOPE_BADGE_CLASSES[group.scope])}>
{SCOPE_LABELS[group.scope]}
</Badge>
<span className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{group.skills.length} skill{group.skills.length === 1 ? '' : 's'}
</span>
</div>
<div className="grid min-w-0 gap-3 lg:grid-cols-2">
{group.skills.map((skill) => (
<div
key={`${skill.command}:${skill.sourcePath}:${skill.projectPath || 'global'}`}
className="min-w-0 rounded-3xl border border-border/70 bg-gradient-to-br from-background via-background to-muted/25 p-4 shadow-sm"
>
<div className="min-w-0 space-y-1">
<div className="break-all font-mono text-sm font-semibold text-foreground">{skill.command}</div>
<div className="text-sm text-muted-foreground">{skill.name}</div>
</div>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
{skill.description || 'No description provided in the skill front matter.'}
</p>
<div className="mt-4 flex flex-wrap items-center gap-2">
{skill.pluginName && (
<Badge variant="outline" className="rounded-full bg-background/70">
Plugin: {skill.pluginName}
</Badge>
)}
{skill.projectDisplayName && (
<Badge variant="outline" className="rounded-full bg-background/70">
Project: {skill.projectDisplayName}
</Badge>
)}
</div>
<div className="mt-4 min-w-0 rounded-2xl border border-border/60 bg-muted/20 px-3 py-2">
<div className="text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">Source</div>
<code className="mt-1 block whitespace-normal break-all text-xs text-foreground">{skill.sourcePath}</code>
</div>
</div>
))}
</div>
</section>
))}
</CardContent>
</Card>
</div> </div>
); );
} }

View File

@@ -1,189 +0,0 @@
import * as React from 'react';
import { ChevronDown, Loader2, type LucideIcon } from 'lucide-react';
import { cn } from '../../../lib/utils';
import { Button } from './Button';
type ButtonVariant = 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
type ButtonSize = 'default' | 'sm' | 'lg' | 'icon';
export type ActionMenuItem = {
key: string;
label: string;
description?: string;
icon?: LucideIcon;
onSelect: () => void;
disabled?: boolean;
loading?: boolean;
isDanger?: boolean;
showDividerBefore?: boolean;
};
type ActionMenuProps = {
label: string;
items: ActionMenuItem[];
icon?: LucideIcon;
ariaLabel?: string;
align?: 'left' | 'right';
variant?: ButtonVariant;
size?: ButtonSize;
className?: string;
triggerClassName?: string;
disabled?: boolean;
};
export default function ActionMenu({
label,
items,
icon: TriggerIcon,
ariaLabel,
align = 'right',
variant = 'outline',
size = 'sm',
className,
triggerClassName,
disabled,
}: ActionMenuProps) {
const [isOpen, setIsOpen] = React.useState(false);
const rootRef = React.useRef<HTMLDivElement | null>(null);
const triggerRef = React.useRef<HTMLButtonElement | null>(null);
const menuRef = React.useRef<HTMLDivElement | null>(null);
// Whether closing should move focus back to the trigger. Set for keyboard
// (Escape) and item selection, but left false for outside pointer clicks so
// focus is not stolen from wherever the user clicked.
const restoreFocusRef = React.useRef(false);
const wasOpenRef = React.useRef(false);
const menuId = React.useId();
React.useEffect(() => {
if (!isOpen) {
return;
}
const closeOnOutsideClick = (event: MouseEvent) => {
const target = event.target as Node;
if (rootRef.current && !rootRef.current.contains(target)) {
setIsOpen(false);
}
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
restoreFocusRef.current = true;
setIsOpen(false);
}
};
document.addEventListener('mousedown', closeOnOutsideClick);
document.addEventListener('keydown', closeOnEscape);
return () => {
document.removeEventListener('mousedown', closeOnOutsideClick);
document.removeEventListener('keydown', closeOnEscape);
};
}, [isOpen]);
// Move focus into the menu on open and back to the trigger on a keyboard or
// selection close, so keyboard and screen-reader navigation match the menu role.
React.useEffect(() => {
if (isOpen) {
wasOpenRef.current = true;
const menu = menuRef.current;
const firstItem = menu?.querySelector<HTMLButtonElement>('[role="menuitem"]:not([disabled])');
(firstItem ?? menu)?.focus();
return;
}
if (wasOpenRef.current) {
wasOpenRef.current = false;
if (restoreFocusRef.current) {
triggerRef.current?.focus();
}
restoreFocusRef.current = false;
}
}, [isOpen]);
const runItem = (item: ActionMenuItem) => {
if (item.disabled || item.loading) {
return;
}
restoreFocusRef.current = true;
setIsOpen(false);
item.onSelect();
};
return (
<div ref={rootRef} className={cn('relative inline-flex', className)}>
<Button
ref={triggerRef}
type="button"
variant={variant}
size={size}
className={triggerClassName}
disabled={disabled}
aria-label={ariaLabel || label}
aria-haspopup="menu"
aria-expanded={isOpen}
aria-controls={isOpen ? menuId : undefined}
onClick={() => setIsOpen((current) => !current)}
>
{TriggerIcon && <TriggerIcon className="h-4 w-4" />}
<span>{label}</span>
<ChevronDown className={cn('h-4 w-4 transition-transform', isOpen && 'rotate-180')} />
</Button>
{isOpen && (
<div
ref={menuRef}
id={menuId}
role="menu"
tabIndex={-1}
className={cn(
'absolute top-full z-50 mt-2 min-w-[220px] rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg',
'animate-in fade-in-0 zoom-in-95',
align === 'right' ? 'right-0' : 'left-0',
)}
>
{items.map((item) => {
const Icon = item.icon;
return (
<React.Fragment key={item.key}>
{item.showDividerBefore && <div className="mx-2 my-1 h-px bg-border" />}
<button
type="button"
role="menuitem"
disabled={item.disabled || item.loading}
onClick={() => runItem(item)}
className={cn(
'flex w-full items-start gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors',
'focus:bg-accent focus:outline-none',
item.disabled || item.loading
? 'cursor-not-allowed opacity-50'
: item.isDanger
? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950'
: 'hover:bg-accent',
)}
>
{item.loading ? (
<Loader2 className="mt-0.5 h-4 w-4 flex-shrink-0 animate-spin" />
) : (
Icon && <Icon className="mt-0.5 h-4 w-4 flex-shrink-0" />
)}
<span className="min-w-0 flex-1">
<span className="block font-medium leading-5">{item.label}</span>
{item.description && (
<span className="mt-0.5 block text-xs leading-4 text-muted-foreground">
{item.description}
</span>
)}
</span>
</button>
</React.Fragment>
);
})}
</div>
)}
</div>
);
}

View File

@@ -92,13 +92,12 @@ DialogTrigger.displayName = 'DialogTrigger';
interface DialogContentProps extends React.HTMLAttributes<HTMLDivElement> { interface DialogContentProps extends React.HTMLAttributes<HTMLDivElement> {
onEscapeKeyDown?: () => void; onEscapeKeyDown?: () => void;
onPointerDownOutside?: () => void; onPointerDownOutside?: () => void;
wrapperClassName?: string;
} }
const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>( const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
({ className, children, onEscapeKeyDown, onPointerDownOutside, wrapperClassName, ...props }, ref) => { ({ className, children, onEscapeKeyDown, onPointerDownOutside, ...props }, ref) => {
const { open, onOpenChange, triggerRef } = useDialog(); const { open, onOpenChange, triggerRef } = useDialog();
const contentRef = React.useRef<HTMLDivElement | null>(null); const contentRef = React.useRef<HTMLDivElement | null>(null);
const previousFocusRef = React.useRef<HTMLElement | null>(null); const previousFocusRef = React.useRef<HTMLElement | null>(null);
@@ -172,7 +171,7 @@ const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
if (!open) return null; if (!open) return null;
return createPortal( return createPortal(
<div className={cn('fixed inset-0 z-50', wrapperClassName)}> <div className="fixed inset-0 z-50">
{/* Overlay */} {/* Overlay */}
<div <div
className="fixed inset-0 animate-dialog-overlay-show bg-black/50 backdrop-blur-sm" className="fixed inset-0 animate-dialog-overlay-show bg-black/50 backdrop-blur-sm"

View File

@@ -1,6 +1,4 @@
export { Alert, AlertTitle, AlertDescription, alertVariants } from './Alert'; export { Alert, AlertTitle, AlertDescription, alertVariants } from './Alert';
export { default as ActionMenu } from './ActionMenu';
export type { ActionMenuItem } from './ActionMenu';
export { Badge, badgeVariants } from './Badge'; export { Badge, badgeVariants } from './Badge';
export { Button, buttonVariants } from './Button'; export { Button, buttonVariants } from './Button';
export { Confirmation, ConfirmationTitle, ConfirmationRequest, ConfirmationAccepted, ConfirmationRejected, ConfirmationActions, ConfirmationAction } from './Confirmation'; export { Confirmation, ConfirmationTitle, ConfirmationRequest, ConfirmationAccepted, ConfirmationRejected, ConfirmationActions, ConfirmationAction } from './Confirmation';