diff --git a/src/components/agents/AgentsPanel.tsx b/src/components/agents/AgentsPanel.tsx new file mode 100644 index 0000000..afa956f --- /dev/null +++ b/src/components/agents/AgentsPanel.tsx @@ -0,0 +1,25 @@ + +import { Bot } from "lucide-react"; + +interface AgentsPanelProps { + onOpenChange: (open: boolean) => void; +} + +export function AgentsPanel({ }: AgentsPanelProps) { + return ( +
+ + +
+
+ +
+

Coming Soon

+

+ The Agents management feature is currently under development. + Stay tuned for powerful autonomous capabilities. +

+
+
+ ); +} diff --git a/src/components/common/FullScreenPanel.tsx b/src/components/common/FullScreenPanel.tsx new file mode 100644 index 0000000..51e1b1b --- /dev/null +++ b/src/components/common/FullScreenPanel.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import { createPortal } from "react-dom"; +import { ArrowLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface FullScreenPanelProps { + isOpen: boolean; + title: string; + onClose: () => void; + children: React.ReactNode; + footer?: React.ReactNode; +} + +/** + * Reusable full-screen panel component + * Handles portal rendering, header with back button, and footer + * Uses solid theme colors without transparency + */ +export const FullScreenPanel: React.FC = ({ + isOpen, + title, + onClose, + children, + footer, +}) => { + if (!isOpen) return null; + + return createPortal( +
+ {/* Header */} +
+ +

+ {title} +

+
+ + {/* Content */} +
+ {children} +
+ + {/* Footer */} + {footer && ( +
+ {footer} +
+ )} +
, + document.body + ); +}; diff --git a/src/components/prompts/PromptFormPanel.tsx b/src/components/prompts/PromptFormPanel.tsx new file mode 100644 index 0000000..99d6c1e --- /dev/null +++ b/src/components/prompts/PromptFormPanel.tsx @@ -0,0 +1,148 @@ +import React, { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import MarkdownEditor from "@/components/MarkdownEditor"; +import { FullScreenPanel } from "@/components/common/FullScreenPanel"; +import type { Prompt, AppId } from "@/lib/api"; + +interface PromptFormPanelProps { + appId: AppId; + editingId?: string; + initialData?: Prompt; + onSave: (id: string, prompt: Prompt) => Promise; + onClose: () => void; +} + +const PromptFormPanel: React.FC = ({ + appId, + editingId, + initialData, + onSave, + onClose, +}) => { + const { t } = useTranslation(); + const appName = t(`apps.${appId}`); + const filenameMap: Record = { + claude: "CLAUDE.md", + codex: "AGENTS.md", + gemini: "GEMINI.md", + }; + const filename = filenameMap[appId]; + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [content, setContent] = useState(""); + const [saving, setSaving] = useState(false); + const [isDarkMode, setIsDarkMode] = useState(false); + + useEffect(() => { + setIsDarkMode(document.documentElement.classList.contains("dark")); + + const observer = new MutationObserver(() => { + setIsDarkMode(document.documentElement.classList.contains("dark")); + }); + + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (initialData) { + setName(initialData.name); + setDescription(initialData.description || ""); + setContent(initialData.content); + } + }, [initialData]); + + const handleSave = async () => { + if (!name.trim() || !content.trim()) { + return; + } + + setSaving(true); + try { + const id = editingId || `prompt-${Date.now()}`; + const timestamp = Math.floor(Date.now() / 1000); + const prompt: Prompt = { + id, + name: name.trim(), + description: description.trim() || undefined, + content: content.trim(), + enabled: initialData?.enabled || false, + createdAt: initialData?.createdAt || timestamp, + updatedAt: timestamp, + }; + await onSave(id, prompt); + onClose(); + } catch (error) { + // Error handled by hook + } finally { + setSaving(false); + } + }; + + const title = editingId + ? t("prompts.editTitle", { appName }) + : t("prompts.addTitle", { appName }); + + return ( + +
+ + setName(e.target.value)} + placeholder={t("prompts.namePlaceholder")} + className="mt-2" + /> +
+ +
+ + setDescription(e.target.value)} + placeholder={t("prompts.descriptionPlaceholder")} + className="mt-2" + /> +
+ +
+ + +
+ +
+ +
+
+ ); +}; + +export default PromptFormPanel; diff --git a/src/components/skills/RepoManagerPanel.tsx b/src/components/skills/RepoManagerPanel.tsx new file mode 100644 index 0000000..a29b554 --- /dev/null +++ b/src/components/skills/RepoManagerPanel.tsx @@ -0,0 +1,207 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Trash2, ExternalLink, Plus } from "lucide-react"; +import { settingsApi } from "@/lib/api"; +import { FullScreenPanel } from "@/components/common/FullScreenPanel"; +import type { Skill, SkillRepo } from "@/lib/api/skills"; + +interface RepoManagerPanelProps { + repos: SkillRepo[]; + skills: Skill[]; + onAdd: (repo: SkillRepo) => Promise; + onRemove: (owner: string, name: string) => Promise; + onClose: () => void; +} + +export function RepoManagerPanel({ + repos, + skills, + onAdd, + onRemove, + onClose, +}: RepoManagerPanelProps) { + const { t } = useTranslation(); + const [repoUrl, setRepoUrl] = useState(""); + const [branch, setBranch] = useState(""); + const [skillsPath, setSkillsPath] = useState(""); + const [error, setError] = useState(""); + + const getSkillCount = (repo: SkillRepo) => + skills.filter( + (skill) => + skill.repoOwner === repo.owner && + skill.repoName === repo.name && + (skill.repoBranch || "main") === (repo.branch || "main"), + ).length; + + const parseRepoUrl = ( + url: string, + ): { owner: string; name: string } | null => { + let cleaned = url.trim(); + cleaned = cleaned.replace(/^https?:\/\/github\.com\//, ""); + cleaned = cleaned.replace(/\.git$/, ""); + + const parts = cleaned.split("/"); + if (parts.length === 2 && parts[0] && parts[1]) { + return { owner: parts[0], name: parts[1] }; + } + + return null; + }; + + const handleAdd = async () => { + setError(""); + + const parsed = parseRepoUrl(repoUrl); + if (!parsed) { + setError(t("skills.repo.invalidUrl")); + return; + } + + try { + await onAdd({ + owner: parsed.owner, + name: parsed.name, + branch: branch || "main", + enabled: true, + skillsPath: skillsPath.trim() || undefined, + }); + + setRepoUrl(""); + setBranch(""); + setSkillsPath(""); + } catch (e) { + setError(e instanceof Error ? e.message : t("skills.repo.addFailed")); + } + }; + + const handleOpenRepo = async (owner: string, name: string) => { + try { + await settingsApi.openExternal(`https://github.com/${owner}/${name}`); + } catch (error) { + console.error("Failed to open URL:", error); + } + }; + + return ( + + {/* 添加仓库表单 */} +
+

添加技能仓库

+
+
+ + setRepoUrl(e.target.value)} + className="mt-2" + /> +
+
+
+ + setBranch(e.target.value)} + className="mt-2" + /> +
+
+ + setSkillsPath(e.target.value)} + className="mt-2" + /> +
+
+ {error &&

{error}

} + +
+
+ + {/* 仓库列表 */} +
+

{t("skills.repo.list")}

+ {repos.length === 0 ? ( +
+

+ {t("skills.repo.empty")} +

+
+ ) : ( +
+ {repos.map((repo) => ( +
+
+
+ {repo.owner}/{repo.name} +
+
+ {t("skills.repo.branch")}: {repo.branch || "main"} + {repo.skillsPath && ( + <> + + {t("skills.repo.path")}: {repo.skillsPath} + + )} + + {t("skills.repo.skillCount", { + count: getSkillCount(repo), + })} + +
+
+
+ + +
+
+ ))} +
+ )} +
+
+ ); +}