Working version 6.2

This commit is contained in:
2026-04-10 14:44:03 +02:00
parent d526c1ff4a
commit f20d5f09ae
28 changed files with 1231 additions and 1554 deletions

View File

@@ -1,224 +0,0 @@
"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>
)}
</>
);
}

View File

@@ -1,87 +0,0 @@
"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>
);
}

View File

@@ -0,0 +1,192 @@
"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;
onToggled: () => void;
}
export function PackageCard({ pkg, enabled, status, tenantName, onToggled }: Props) {
const t = useTranslations();
const [showModal, setShowModal] = useState(false);
const [secrets, setSecrets] = useState<Record<string, string>>({});
const [accepted, setAccepted] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleEnable() {
if (pkg.requiresSecrets) {
setShowModal(true);
setSecrets({});
setAccepted(false);
setError(null);
return;
}
await togglePackage(true);
}
async function togglePackage(enable: boolean) {
setSaving(true);
try {
const res = await fetch(`/api/tenants/${tenantName}`);
const tenant = await res.json();
const current: string[] = tenant.spec?.packages || [];
const next = enable
? [...current, pkg.id]
: current.filter((p: string) => p !== pkg.id);
const patchRes = await fetch(`/api/tenants/${tenantName}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ packages: next }),
});
if (!patchRes.ok) throw new Error("Failed to update packages");
onToggled();
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
}
async function handleSubmitSecrets() {
if (pkg.disclaimerKey && !accepted) return;
const required = (pkg.secrets || []).map((s) => s.key);
const missing = required.filter((k) => !secrets[k]?.trim());
if (missing.length) { setError(t("packages.missingFields")); return; }
setSaving(true);
setError(null);
try {
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");
}
await togglePackage(true);
setShowModal(false);
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
}
const statusColors: Record<string, string> = {
pending: "text-warning",
active: "text-success",
error: "text-error",
};
return (
<>
<div className="bg-surface-1 border border-border rounded-xl p-5 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-text-primary">{pkg.name}</span>
<span className="text-[10px] font-semibold uppercase tracking-wider text-text-muted bg-surface-3 px-1.5 py-0.5 rounded">
{pkg.category}
</span>
</div>
<p className="text-xs text-text-secondary mt-1">{t(pkg.descriptionKey)}</p>
</div>
{enabled && status && (
<span className={`text-xs font-medium ${statusColors[status] || ""}`}>
{t(`packages.status.${status}`)}
</span>
)}
</div>
<div className="flex items-center justify-between mt-auto pt-3 border-t border-border">
{pkg.requiresSecrets && (
<span className="text-[10px] text-text-muted">{t("packages.requiresApiKey")}</span>
)}
<button
onClick={enabled ? () => togglePackage(false) : handleEnable}
disabled={saving}
className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
enabled
? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2"
: "bg-accent text-surface-0 hover:bg-accent-dim shadow-lg shadow-accent/20"
} disabled:opacity-50`}
>
{saving ? "…" : enabled ? t("packages.disable") : t("packages.enable")}
</button>
</div>
</div>
{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 bg-surface-1 border border-border rounded-2xl p-6 space-y-4 shadow-2xl shadow-black/40">
<h3 className="font-display text-base font-semibold text-text-primary">
{t("packages.configure")} {pkg.name}
</h3>
{pkg.instructionsKey && (
<div className="bg-surface-2 border border-border rounded-lg p-3 text-xs text-text-secondary leading-relaxed whitespace-pre-line">
{t(pkg.instructionsKey)}
</div>
)}
<div className="space-y-3">
{(pkg.secrets || []).map((s) => (
<label key={s.key} className="block">
<span className="text-xs text-text-secondary mb-1 block">{t(s.labelKey)}</span>
<input
type="password"
placeholder={t(s.placeholderKey)}
value={secrets[s.key] || ""}
onChange={(e) => setSecrets((p) => ({ ...p, [s.key]: e.target.value }))}
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none"
/>
</label>
))}
</div>
{pkg.disclaimerKey && (
<label className="flex items-start gap-2 text-xs text-text-secondary">
<input
type="checkbox"
checked={accepted}
onChange={(e) => setAccepted(e.target.checked)}
className="mt-0.5 accent-accent"
/>
<span>{t(pkg.disclaimerKey)}</span>
</label>
)}
{error && <p className="text-xs text-error">{error}</p>}
<div className="flex justify-end gap-2 pt-2">
<button
onClick={() => setShowModal(false)}
className="rounded-lg px-3 py-1.5 text-xs text-text-secondary hover:text-text-primary cursor-pointer"
>
{t("common.cancel")}
</button>
<button
onClick={handleSubmitSecrets}
disabled={saving || (!!pkg.disclaimerKey && !accepted)}
className="rounded-lg bg-accent px-4 py-1.5 text-xs font-medium text-surface-0 hover:bg-accent-dim disabled:opacity-50 cursor-pointer shadow-lg shadow-accent/20"
>
{saving ? "…" : t("packages.enableAndSave")}
</button>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import { useRouter } from "next/navigation";
import { PACKAGE_CATALOG } from "@/lib/packages";
import { PackageCard } from "./package-card";
import type { PiecedTenantStatus } from "@/types";
interface Props {
tenantName: string;
enabledPackages: string[];
conditions?: PiecedTenantStatus["conditions"];
}
export function PackageList({ tenantName, enabledPackages, conditions }: Props) {
const router = useRouter();
function getStatus(pkgId: string): "pending" | "active" | "error" | undefined {
if (!conditions) return enabledPackages.includes(pkgId) ? "pending" : undefined;
const cond = conditions.find((c) => c.type === `Package/${pkgId}`);
if (!cond) return enabledPackages.includes(pkgId) ? "pending" : undefined;
if (cond.status === "True") return "active";
if (cond.status === "False") return "error";
return "pending";
}
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{PACKAGE_CATALOG.map((pkg) => (
<PackageCard
key={pkg.id}
pkg={pkg}
enabled={enabledPackages.includes(pkg.id)}
status={enabledPackages.includes(pkg.id) ? getStatus(pkg.id) : undefined}
tenantName={tenantName}
onToggled={() => router.refresh()}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,88 @@
"use client";
import { useTranslations } from "next-intl";
import { useState } from "react";
const FILE_TABS = ["SOUL.md", "AGENTS.md", "TOOLS.md"] as const;
interface Props {
tenantName: string;
files: Record<string, string>;
}
export function WorkspaceEditor({ tenantName, files }: Props) {
const t = useTranslations("workspace");
const [activeTab, setActiveTab] = useState<string>("SOUL.md");
const [localFiles, setLocalFiles] = useState<Record<string, string>>(files);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [error, setError] = useState<string | null>(null);
function handleChange(content: string) {
setLocalFiles((prev) => ({ ...prev, [activeTab]: content }));
setDirty(true);
}
async function handleSave() {
setSaving(true);
setError(null);
try {
const res = await fetch(`/api/tenants/${tenantName}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workspaceFiles: localFiles }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Save failed");
}
setDirty(false);
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
}
return (
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<div className="flex gap-1">
{FILE_TABS.map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`rounded-md px-2.5 py-1 text-xs font-mono transition-colors cursor-pointer ${
activeTab === tab
? "bg-surface-3 text-accent"
: "text-text-muted hover:text-text-secondary"
}`}
>
{tab}
</button>
))}
</div>
<button
onClick={handleSave}
disabled={!dirty || saving}
className="rounded-lg bg-accent px-3 py-1 text-xs font-medium text-surface-0 hover:bg-accent-dim disabled:opacity-40 cursor-pointer"
>
{saving ? "…" : t("save")}
</button>
</div>
<textarea
value={localFiles[activeTab] || ""}
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-text-secondary placeholder:text-text-muted focus:outline-none"
placeholder={t("placeholder", { file: activeTab })}
/>
<div className="border-t border-border px-4 py-2 flex items-center justify-between">
<p className="text-[10px] text-text-muted leading-relaxed">{t("seedingNote")}</p>
{error && <p className="text-[10px] text-error">{error}</p>}
</div>
</div>
);
}