feat(components): add reusable full-screen panel components
Add new full-screen panel components to support the UI refactoring: - FullScreenPanel: Reusable full-screen layout component with header, content area, and optional footer. Provides consistent layout for settings, prompts, and other full-screen views. - PromptFormPanel: Dedicated panel for creating and editing prompts with markdown preview support. Features real-time validation and integrated save/cancel actions. - AgentsPanel: Panel component for managing agent configurations. Provides a consistent interface for agent CRUD operations. - RepoManagerPanel: Full-featured repository manager panel for Skills. Supports repository listing, addition, deletion, and configuration management with integrated validation. These components establish the foundation for the upcoming settings page migration from dialog-based to full-screen layout.
This commit is contained in:
25
src/components/agents/AgentsPanel.tsx
Normal file
25
src/components/agents/AgentsPanel.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
|
||||||
|
import { Bot } from "lucide-react";
|
||||||
|
|
||||||
|
interface AgentsPanelProps {
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentsPanel({ }: AgentsPanelProps) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl flex flex-col h-[calc(100vh-8rem)]">
|
||||||
|
|
||||||
|
|
||||||
|
<div className="flex-1 glass-card rounded-xl p-8 flex flex-col items-center justify-center text-center space-y-4">
|
||||||
|
<div className="w-20 h-20 rounded-full bg-white/5 flex items-center justify-center mb-4 animate-pulse-slow">
|
||||||
|
<Bot className="w-10 h-10 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold">Coming Soon</h3>
|
||||||
|
<p className="text-muted-foreground max-w-md">
|
||||||
|
The Agents management feature is currently under development.
|
||||||
|
Stay tuned for powerful autonomous capabilities.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
src/components/common/FullScreenPanel.tsx
Normal file
60
src/components/common/FullScreenPanel.tsx
Normal file
@@ -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<FullScreenPanelProps> = ({
|
||||||
|
isOpen,
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
}) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-[60] flex flex-col" style={{ backgroundColor: 'hsl(var(--background))' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex-shrink-0 px-6 py-4 flex items-center gap-4" style={{ backgroundColor: 'hsl(var(--background))' }}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClose}
|
||||||
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-6 space-y-6 max-w-5xl mx-auto w-full">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{footer && (
|
||||||
|
<div className="flex-shrink-0 px-6 py-4 flex items-center justify-end gap-3" style={{ backgroundColor: 'hsl(var(--background))' }}>
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
};
|
||||||
148
src/components/prompts/PromptFormPanel.tsx
Normal file
148
src/components/prompts/PromptFormPanel.tsx
Normal file
@@ -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<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
|
||||||
|
appId,
|
||||||
|
editingId,
|
||||||
|
initialData,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const appName = t(`apps.${appId}`);
|
||||||
|
const filenameMap: Record<AppId, string> = {
|
||||||
|
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 (
|
||||||
|
<FullScreenPanel
|
||||||
|
isOpen={true}
|
||||||
|
title={title}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="name" className="text-foreground">{t("prompts.name")}</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={t("prompts.namePlaceholder")}
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="description" className="text-foreground">{t("prompts.description")}</Label>
|
||||||
|
<Input
|
||||||
|
id="description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder={t("prompts.descriptionPlaceholder")}
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="content" className="block mb-2 text-foreground">
|
||||||
|
{t("prompts.content")}
|
||||||
|
</Label>
|
||||||
|
<MarkdownEditor
|
||||||
|
value={content}
|
||||||
|
onChange={setContent}
|
||||||
|
placeholder={t("prompts.contentPlaceholder", { filename })}
|
||||||
|
darkMode={isDarkMode}
|
||||||
|
minHeight="500px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-6">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!name.trim() || !content.trim() || saving}
|
||||||
|
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{saving ? t("common.saving") : t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FullScreenPanel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PromptFormPanel;
|
||||||
207
src/components/skills/RepoManagerPanel.tsx
Normal file
207
src/components/skills/RepoManagerPanel.tsx
Normal file
@@ -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<void>;
|
||||||
|
onRemove: (owner: string, name: string) => Promise<void>;
|
||||||
|
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 (
|
||||||
|
<FullScreenPanel
|
||||||
|
isOpen={true}
|
||||||
|
title={t("skills.repo.title")}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
{/* 添加仓库表单 */}
|
||||||
|
<div className="space-y-4 glass-card rounded-xl p-6 border border-border/10">
|
||||||
|
<h3 className="text-base font-semibold text-foreground">添加技能仓库</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="repo-url" className="text-foreground">{t("skills.repo.url")}</Label>
|
||||||
|
<Input
|
||||||
|
id="repo-url"
|
||||||
|
placeholder={t("skills.repo.urlPlaceholder")}
|
||||||
|
value={repoUrl}
|
||||||
|
onChange={(e) => setRepoUrl(e.target.value)}
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="branch" className="text-foreground">{t("skills.repo.branch")}</Label>
|
||||||
|
<Input
|
||||||
|
id="branch"
|
||||||
|
placeholder={t("skills.repo.branchPlaceholder")}
|
||||||
|
value={branch}
|
||||||
|
onChange={(e) => setBranch(e.target.value)}
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="skills-path" className="text-foreground">{t("skills.repo.path")}</Label>
|
||||||
|
<Input
|
||||||
|
id="skills-path"
|
||||||
|
placeholder={t("skills.repo.pathPlaceholder")}
|
||||||
|
value={skillsPath}
|
||||||
|
onChange={(e) => setSkillsPath(e.target.value)}
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||||
|
<Button
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
{t("skills.repo.add")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 仓库列表 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-base font-semibold text-foreground">{t("skills.repo.list")}</h3>
|
||||||
|
{repos.length === 0 ? (
|
||||||
|
<div className="text-center py-12 glass-card rounded-xl border border-border/10">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("skills.repo.empty")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{repos.map((repo) => (
|
||||||
|
<div
|
||||||
|
key={`${repo.owner}/${repo.name}`}
|
||||||
|
className="flex items-center justify-between rounded-xl border border-border/10 glass-card px-4 py-3"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-foreground">
|
||||||
|
{repo.owner}/{repo.name}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{t("skills.repo.branch")}: {repo.branch || "main"}
|
||||||
|
{repo.skillsPath && (
|
||||||
|
<>
|
||||||
|
<span className="mx-2">•</span>
|
||||||
|
{t("skills.repo.path")}: {repo.skillsPath}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="ml-3 inline-flex items-center rounded-full border border-border-default px-2 py-0.5 text-[11px]">
|
||||||
|
{t("skills.repo.skillCount", {
|
||||||
|
count: getSkillCount(repo),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleOpenRepo(repo.owner, repo.name)}
|
||||||
|
title={t("common.view", { defaultValue: "查看" })}
|
||||||
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemove(repo.owner, repo.name)}
|
||||||
|
title={t("common.delete")}
|
||||||
|
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FullScreenPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user