Add initial Portal version
This commit is contained in:
224
src/components/packages/PackageCard.tsx
Normal file
224
src/components/packages/PackageCard.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import type { PackageDef } from "@/lib/packages";
|
||||
|
||||
interface Props {
|
||||
pkg: PackageDef;
|
||||
enabled: boolean;
|
||||
status?: "pending" | "active" | "error";
|
||||
tenantName: string;
|
||||
onToggle: (
|
||||
packageId: string,
|
||||
enable: boolean,
|
||||
secrets?: Record<string, string>
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function PackageCard({
|
||||
pkg,
|
||||
enabled,
|
||||
status,
|
||||
tenantName,
|
||||
onToggle,
|
||||
}: Props) {
|
||||
const t = useTranslations();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [secrets, setSecrets] = useState<Record<string, string>>({});
|
||||
const [disclaimerAccepted, setDisclaimerAccepted] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const statusStyles = {
|
||||
pending: "text-amber-400",
|
||||
active: "text-emerald-400",
|
||||
error: "text-red-400",
|
||||
};
|
||||
|
||||
async function handleEnable() {
|
||||
if (pkg.requiresSecrets) {
|
||||
setShowModal(true);
|
||||
setSecrets({});
|
||||
setDisclaimerAccepted(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await onToggle(pkg.id, true);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisable() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onToggle(pkg.id, false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitSecrets() {
|
||||
if (!disclaimerAccepted && pkg.disclaimerKey) return;
|
||||
|
||||
const requiredKeys = (pkg.secrets || []).map((s) => s.key);
|
||||
const missing = requiredKeys.filter((k) => !secrets[k]?.trim());
|
||||
if (missing.length > 0) {
|
||||
setError(t("packages.missingFields"));
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Write secrets first
|
||||
const secretRes = await fetch(
|
||||
`/api/tenants/${tenantName}/secrets`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ packageId: pkg.id, secrets }),
|
||||
}
|
||||
);
|
||||
if (!secretRes.ok) {
|
||||
const err = await secretRes.json();
|
||||
throw new Error(err.error || "Failed to store secrets");
|
||||
}
|
||||
|
||||
// Then enable the package
|
||||
await onToggle(pkg.id, true);
|
||||
setShowModal(false);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-4 flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-zinc-200">
|
||||
{pkg.name}
|
||||
</span>
|
||||
<span className="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-500 uppercase tracking-wide">
|
||||
{pkg.category}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 mt-1">
|
||||
{t(pkg.descriptionKey)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{enabled && status && (
|
||||
<span className={`text-xs ${statusStyles[status] || ""}`}>
|
||||
{t(`packages.status.${status}`)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-auto pt-2 border-t border-zinc-800/50">
|
||||
{pkg.requiresSecrets && (
|
||||
<span className="text-[10px] text-zinc-600">
|
||||
{t("packages.requiresApiKey")}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={enabled ? handleDisable : handleEnable}
|
||||
disabled={saving}
|
||||
className={`ml-auto rounded px-3 py-1 text-xs font-medium transition-colors ${
|
||||
enabled
|
||||
? "bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
|
||||
: "bg-teal-600 text-white hover:bg-teal-500"
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{saving
|
||||
? "..."
|
||||
: enabled
|
||||
? t("packages.disable")
|
||||
: t("packages.enable")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secret input modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="w-full max-w-md rounded-lg border border-zinc-700 bg-zinc-900 p-6 space-y-4">
|
||||
<h3 className="text-base font-medium text-zinc-100">
|
||||
{t("packages.configure")} {pkg.name}
|
||||
</h3>
|
||||
|
||||
{pkg.customerInstructionsKey && (
|
||||
<div className="rounded bg-zinc-800/50 border border-zinc-700/50 p-3 text-xs text-zinc-400 leading-relaxed">
|
||||
{t(pkg.customerInstructionsKey)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{(pkg.secrets || []).map((secret) => (
|
||||
<label key={secret.key} className="block">
|
||||
<span className="text-xs text-zinc-400 mb-1 block">
|
||||
{t(secret.labelKey)}
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={t(secret.placeholderKey)}
|
||||
value={secrets[secret.key] || ""}
|
||||
onChange={(e) =>
|
||||
setSecrets((prev) => ({
|
||||
...prev,
|
||||
[secret.key]: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full rounded border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-200 placeholder:text-zinc-600 focus:border-teal-600 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{pkg.disclaimerKey && (
|
||||
<label className="flex items-start gap-2 text-xs text-zinc-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={disclaimerAccepted}
|
||||
onChange={(e) => setDisclaimerAccepted(e.target.checked)}
|
||||
className="mt-0.5 rounded border-zinc-600 bg-zinc-800 accent-teal-500"
|
||||
/>
|
||||
<span>{t(pkg.disclaimerKey)}</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="rounded px-3 py-1.5 text-xs text-zinc-400 hover:text-zinc-200"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmitSecrets}
|
||||
disabled={
|
||||
saving || (!!pkg.disclaimerKey && !disclaimerAccepted)
|
||||
}
|
||||
className="rounded bg-teal-600 px-4 py-1.5 text-xs font-medium text-white hover:bg-teal-500 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "..." : t("packages.enableAndSave")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
87
src/components/packages/WorkspaceEditor.tsx
Normal file
87
src/components/packages/WorkspaceEditor.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
|
||||
interface WorkspaceFile {
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tenantName: string;
|
||||
files: WorkspaceFile[];
|
||||
onSave: (files: WorkspaceFile[]) => Promise<void>;
|
||||
}
|
||||
|
||||
const FILE_TABS = ["SOUL.md", "AGENTS.md", "TOOLS.md"] as const;
|
||||
|
||||
export default function WorkspaceEditor({ tenantName, files, onSave }: Props) {
|
||||
const t = useTranslations("workspace");
|
||||
const [activeTab, setActiveTab] = useState<string>("SOUL.md");
|
||||
const [localFiles, setLocalFiles] = useState<WorkspaceFile[]>(files);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
const activeFile = localFiles.find((f) => f.name === activeTab);
|
||||
|
||||
function handleChange(content: string) {
|
||||
setLocalFiles((prev) =>
|
||||
prev.map((f) => (f.name === activeTab ? { ...f, content } : f))
|
||||
);
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(localFiles);
|
||||
setDirty(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/50">
|
||||
<div className="flex items-center justify-between border-b border-zinc-800 px-4 py-2">
|
||||
<div className="flex gap-1">
|
||||
{FILE_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`rounded px-2.5 py-1 text-xs font-mono transition-colors ${
|
||||
activeTab === tab
|
||||
? "bg-zinc-800 text-teal-400"
|
||||
: "text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
className="rounded bg-teal-600 px-3 py-1 text-xs font-medium text-white hover:bg-teal-500 disabled:opacity-40"
|
||||
>
|
||||
{saving ? "..." : t("save")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={activeFile?.content || ""}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="w-full min-h-[300px] resize-y bg-transparent p-4 font-mono text-sm text-zinc-300 placeholder:text-zinc-700 focus:outline-none"
|
||||
placeholder={t("placeholder", { file: activeTab })}
|
||||
/>
|
||||
|
||||
<div className="border-t border-zinc-800 px-4 py-2">
|
||||
<p className="text-[10px] text-zinc-600 leading-relaxed">
|
||||
{t("seedingNote")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user