Add initial Portal version
This commit is contained in:
193
src/components/admin/AdminTenantsClient.tsx
Normal file
193
src/components/admin/AdminTenantsClient.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PhaseBadge } from "@/components/dashboard/InstanceStatus";
|
||||
|
||||
interface TenantRow {
|
||||
name: string;
|
||||
displayName?: string;
|
||||
phase: string;
|
||||
packages: string[];
|
||||
agentName?: string;
|
||||
created: string;
|
||||
orgId?: string;
|
||||
}
|
||||
|
||||
type SortKey = "name" | "phase" | "packages" | "created";
|
||||
type SortDir = "asc" | "desc";
|
||||
|
||||
export default function AdminTenantsClient() {
|
||||
const t = useTranslations("admin");
|
||||
const router = useRouter();
|
||||
const [tenants, setTenants] = useState<TenantRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sortKey, setSortKey] = useState<SortKey>("name");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/tenants")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const items = data.items || data || [];
|
||||
setTenants(
|
||||
items.map((t: any) => ({
|
||||
name: t.metadata.name,
|
||||
displayName: t.spec?.displayName,
|
||||
phase: t.status?.phase || "Pending",
|
||||
packages: t.spec?.packages || [],
|
||||
agentName: t.spec?.agentName,
|
||||
created: t.metadata.creationTimestamp,
|
||||
orgId: t.metadata?.labels?.["zitadel-org-id"],
|
||||
}))
|
||||
);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sortKey === key) {
|
||||
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir("asc");
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...tenants].sort((a, b) => {
|
||||
let cmp = 0;
|
||||
switch (sortKey) {
|
||||
case "name":
|
||||
cmp = (a.displayName || a.name).localeCompare(
|
||||
b.displayName || b.name
|
||||
);
|
||||
break;
|
||||
case "phase":
|
||||
cmp = a.phase.localeCompare(b.phase);
|
||||
break;
|
||||
case "packages":
|
||||
cmp = a.packages.length - b.packages.length;
|
||||
break;
|
||||
case "created":
|
||||
cmp =
|
||||
new Date(a.created).getTime() - new Date(b.created).getTime();
|
||||
break;
|
||||
}
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
}, [tenants, sortKey, sortDir]);
|
||||
|
||||
const SortHeader = ({
|
||||
label,
|
||||
field,
|
||||
}: {
|
||||
label: string;
|
||||
field: SortKey;
|
||||
}) => (
|
||||
<th
|
||||
onClick={() => toggleSort(field)}
|
||||
className="cursor-pointer select-none px-3 py-2 text-left text-xs font-medium text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
{label}
|
||||
{sortKey === field && (
|
||||
<span className="ml-1 text-teal-400">
|
||||
{sortDir === "asc" ? "↑" : "↓"}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 w-40 bg-zinc-800 rounded mb-4" />
|
||||
<div className="h-64 bg-zinc-900/50 border border-zinc-800 rounded-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-zinc-100">
|
||||
{t("tenants")}
|
||||
</h1>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{tenants.length} {t("total")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/50 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-zinc-800">
|
||||
<tr>
|
||||
<SortHeader label={t("name")} field="name" />
|
||||
<SortHeader label={t("phase")} field="phase" />
|
||||
<SortHeader label={t("packages")} field="packages" />
|
||||
<SortHeader label={t("created")} field="created" />
|
||||
<th className="px-3 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/50">
|
||||
{sorted.map((row) => (
|
||||
<tr
|
||||
key={row.name}
|
||||
className="hover:bg-zinc-800/30 transition-colors"
|
||||
>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="text-zinc-200">
|
||||
{row.displayName || row.name}
|
||||
</div>
|
||||
{row.agentName && (
|
||||
<div className="text-[10px] text-zinc-600">
|
||||
{row.agentName}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<PhaseBadge phase={row.phase} />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{row.packages.length === 0 ? (
|
||||
<span className="text-xs text-zinc-600">—</span>
|
||||
) : (
|
||||
row.packages.map((p) => (
|
||||
<span
|
||||
key={p}
|
||||
className="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-400"
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-xs text-zinc-500">
|
||||
{new Date(row.created).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right">
|
||||
<button
|
||||
onClick={() => router.push(`/tenants/${row.name}`)}
|
||||
className="text-xs text-teal-400 hover:text-teal-300"
|
||||
>
|
||||
{t("manage")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{sorted.length === 0 && (
|
||||
<div className="py-12 text-center text-sm text-zinc-600">
|
||||
{t("noTenants")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
src/components/dashboard/DashboardClient.tsx
Normal file
124
src/components/dashboard/DashboardClient.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import InstanceStatus from "@/components/dashboard/InstanceStatus";
|
||||
import UsageDisplay from "@/components/dashboard/UsageDisplay";
|
||||
|
||||
interface TenantSummary {
|
||||
metadata: {
|
||||
name: string;
|
||||
creationTimestamp: string;
|
||||
};
|
||||
spec: {
|
||||
agentName?: string;
|
||||
packages?: string[];
|
||||
litellmTeamId?: string;
|
||||
};
|
||||
status?: {
|
||||
phase: string;
|
||||
conditions?: { type: string; status: string; message?: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const t = useTranslations("dashboard");
|
||||
const { data: session } = useSession();
|
||||
const router = useRouter();
|
||||
const [tenant, setTenant] = useState<TenantSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/tenants")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
// For non-platform users: pick first tenant from the list
|
||||
const items = data.items || data || [];
|
||||
if (items.length > 0) {
|
||||
setTenant(items[0]);
|
||||
}
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4 animate-pulse">
|
||||
<div className="h-32 rounded-lg bg-zinc-900/50 border border-zinc-800" />
|
||||
<div className="h-64 rounded-lg bg-zinc-900/50 border border-zinc-800" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No tenant yet — show CTA
|
||||
if (!tenant) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="rounded-full bg-teal-950/50 border border-teal-800/30 p-4 mb-4">
|
||||
<svg
|
||||
className="h-8 w-8 text-teal-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 4.5v15m7.5-7.5h-15"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-lg font-medium text-zinc-200 mb-1">
|
||||
{t("noInstance")}
|
||||
</h2>
|
||||
<p className="text-sm text-zinc-500 mb-6 max-w-sm">
|
||||
{t("noInstanceDescription")}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push("/onboarding")}
|
||||
className="rounded-lg bg-teal-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-teal-500 transition-colors"
|
||||
>
|
||||
{t("getStarted")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tenantName = tenant.metadata.name;
|
||||
const teamId = tenant.spec.litellmTeamId || tenantName;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-semibold text-zinc-100">
|
||||
{t("title")}
|
||||
</h1>
|
||||
|
||||
<InstanceStatus
|
||||
phase={tenant.status?.phase || "Pending"}
|
||||
conditions={tenant.status?.conditions}
|
||||
agentName={tenant.spec.agentName}
|
||||
packages={tenant.spec.packages}
|
||||
createdAt={tenant.metadata.creationTimestamp}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-zinc-300 mb-3">
|
||||
{t("usageTitle")}
|
||||
</h2>
|
||||
<UsageDisplay teamId={teamId} />
|
||||
</div>
|
||||
|
||||
{/* Quick link to tenant settings */}
|
||||
<button
|
||||
onClick={() => router.push(`/tenants/${tenantName}`)}
|
||||
className="text-xs text-teal-400 hover:text-teal-300 transition-colors"
|
||||
>
|
||||
{t("manageInstance")} →
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
src/components/dashboard/InstanceStatus.tsx
Normal file
111
src/components/dashboard/InstanceStatus.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type Phase = "Running" | "Provisioning" | "Pending" | "Error" | string;
|
||||
|
||||
interface Props {
|
||||
phase: Phase;
|
||||
conditions?: { type: string; status: string; message?: string }[];
|
||||
agentName?: string;
|
||||
packages?: string[];
|
||||
createdAt?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const PHASE_STYLES: Record<string, { bg: string; text: string; dot: string }> = {
|
||||
Running: {
|
||||
bg: "bg-emerald-950/50 border-emerald-800/50",
|
||||
text: "text-emerald-400",
|
||||
dot: "bg-emerald-400 animate-pulse",
|
||||
},
|
||||
Provisioning: {
|
||||
bg: "bg-amber-950/50 border-amber-800/50",
|
||||
text: "text-amber-400",
|
||||
dot: "bg-amber-400 animate-pulse",
|
||||
},
|
||||
Pending: {
|
||||
bg: "bg-zinc-800/50 border-zinc-700/50",
|
||||
text: "text-zinc-400",
|
||||
dot: "bg-zinc-400",
|
||||
},
|
||||
Error: {
|
||||
bg: "bg-red-950/50 border-red-800/50",
|
||||
text: "text-red-400",
|
||||
dot: "bg-red-400",
|
||||
},
|
||||
};
|
||||
|
||||
export function PhaseBadge({ phase }: { phase: Phase }) {
|
||||
const style = PHASE_STYLES[phase] || PHASE_STYLES.Pending;
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${style.bg} ${style.text}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${style.dot}`} />
|
||||
{phase}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InstanceStatus({
|
||||
phase,
|
||||
conditions,
|
||||
agentName,
|
||||
packages,
|
||||
createdAt,
|
||||
compact = false,
|
||||
}: Props) {
|
||||
const t = useTranslations("dashboard");
|
||||
|
||||
if (compact) {
|
||||
return <PhaseBadge phase={phase} />;
|
||||
}
|
||||
|
||||
const errorCondition = conditions?.find(
|
||||
(c) => c.status === "False" && c.message
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-zinc-300">
|
||||
{t("instanceStatus")}
|
||||
</h3>
|
||||
<PhaseBadge phase={phase} />
|
||||
</div>
|
||||
|
||||
{agentName && (
|
||||
<div className="text-sm">
|
||||
<span className="text-zinc-500">{t("agentName")}:</span>{" "}
|
||||
<span className="text-zinc-200">{agentName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{packages && packages.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{packages.map((pkg) => (
|
||||
<span
|
||||
key={pkg}
|
||||
className="rounded bg-teal-950/50 border border-teal-800/30 px-2 py-0.5 text-xs text-teal-300"
|
||||
>
|
||||
{pkg}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createdAt && (
|
||||
<div className="text-xs text-zinc-500">
|
||||
{t("created")}: {new Date(createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorCondition && (
|
||||
<div className="rounded bg-red-950/30 border border-red-900/30 p-2 text-xs text-red-300">
|
||||
{errorCondition.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
216
src/components/dashboard/UsageDisplay.tsx
Normal file
216
src/components/dashboard/UsageDisplay.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface DailyUsage {
|
||||
date: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalCostCHF: number;
|
||||
}
|
||||
|
||||
interface UsageData {
|
||||
currentPeriod: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
inputCostCHF: number;
|
||||
outputCostCHF: number;
|
||||
totalCostCHF: number;
|
||||
};
|
||||
budget: {
|
||||
maxBudget: number | null;
|
||||
spend: number;
|
||||
remaining: number | null;
|
||||
};
|
||||
rateLimits: {
|
||||
rpm: number | null;
|
||||
tpm: number | null;
|
||||
};
|
||||
dailyUsage: DailyUsage[];
|
||||
}
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
|
||||
return n.toString();
|
||||
}
|
||||
|
||||
function formatCHF(n: number): string {
|
||||
return `CHF ${n.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function UsageChart({ data }: { data: DailyUsage[] }) {
|
||||
if (!data.length) return null;
|
||||
|
||||
const maxTokens = Math.max(
|
||||
...data.map((d) => d.inputTokens + d.outputTokens),
|
||||
1
|
||||
);
|
||||
const barWidth = Math.max(4, Math.floor(600 / data.length) - 2);
|
||||
const chartHeight = 120;
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<svg
|
||||
viewBox={`0 0 ${Math.max(data.length * (barWidth + 2), 600)} ${chartHeight + 24}`}
|
||||
className="w-full h-36"
|
||||
preserveAspectRatio="xMinYMid meet"
|
||||
>
|
||||
{data.map((d, i) => {
|
||||
const total = d.inputTokens + d.outputTokens;
|
||||
const totalH = (total / maxTokens) * chartHeight;
|
||||
const inputH = (d.inputTokens / maxTokens) * chartHeight;
|
||||
const x = i * (barWidth + 2);
|
||||
|
||||
return (
|
||||
<g key={d.date}>
|
||||
<title>
|
||||
{d.date}: {formatTokens(d.inputTokens)} in / {formatTokens(d.outputTokens)} out
|
||||
</title>
|
||||
{/* Output tokens (bottom) */}
|
||||
<rect
|
||||
x={x}
|
||||
y={chartHeight - totalH}
|
||||
width={barWidth}
|
||||
height={totalH - inputH}
|
||||
rx={1}
|
||||
className="fill-teal-700/60"
|
||||
/>
|
||||
{/* Input tokens (top) */}
|
||||
<rect
|
||||
x={x}
|
||||
y={chartHeight - inputH}
|
||||
width={barWidth}
|
||||
height={inputH}
|
||||
rx={1}
|
||||
className="fill-teal-400/80"
|
||||
/>
|
||||
{/* Date label (every 7th) */}
|
||||
{i % 7 === 0 && (
|
||||
<text
|
||||
x={x + barWidth / 2}
|
||||
y={chartHeight + 14}
|
||||
textAnchor="middle"
|
||||
className="fill-zinc-500 text-[8px]"
|
||||
>
|
||||
{d.date.slice(5)}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<div className="flex items-center gap-4 text-xs text-zinc-500 mt-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block h-2 w-2 rounded-sm bg-teal-400/80" /> Input
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block h-2 w-2 rounded-sm bg-teal-700/60" /> Output
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UsageDisplay({ teamId }: { teamId: string | null }) {
|
||||
const t = useTranslations("dashboard");
|
||||
const [data, setData] = useState<UsageData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!teamId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/usage?teamId=${encodeURIComponent(teamId)}`)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then(setData)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [teamId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-4 animate-pulse">
|
||||
<div className="h-4 w-32 bg-zinc-800 rounded mb-4" />
|
||||
<div className="h-36 bg-zinc-800/50 rounded" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-4">
|
||||
<p className="text-sm text-zinc-500">
|
||||
{error ? t("usageError") : t("noUsageData")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { currentPeriod, budget, rateLimits, dailyUsage } = data;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Spend summary cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<StatCard label={t("inputTokens")} value={formatTokens(currentPeriod.inputTokens)} sub={formatCHF(currentPeriod.inputCostCHF)} />
|
||||
<StatCard label={t("outputTokens")} value={formatTokens(currentPeriod.outputTokens)} sub={formatCHF(currentPeriod.outputCostCHF)} />
|
||||
<StatCard label={t("totalCost")} value={formatCHF(currentPeriod.totalCostCHF)} accent />
|
||||
{budget.remaining !== null ? (
|
||||
<StatCard label={t("budgetRemaining")} value={formatCHF(budget.remaining)} />
|
||||
) : (
|
||||
<StatCard label={t("budget")} value={t("noBudgetSet")} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rate limits */}
|
||||
{(rateLimits.rpm || rateLimits.tpm) && (
|
||||
<div className="flex gap-4 text-xs text-zinc-500">
|
||||
{rateLimits.rpm && <span>RPM limit: {rateLimits.rpm}</span>}
|
||||
{rateLimits.tpm && <span>TPM limit: {formatTokens(rateLimits.tpm)}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chart */}
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-4">
|
||||
<h3 className="text-sm font-medium text-zinc-300 mb-3">
|
||||
{t("last30Days")}
|
||||
</h3>
|
||||
<UsageChart data={dailyUsage} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
accent,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
accent?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-3">
|
||||
<div className="text-xs text-zinc-500 mb-1">{label}</div>
|
||||
<div
|
||||
className={`text-lg font-semibold tabular-nums ${
|
||||
accent ? "text-teal-400" : "text-zinc-200"
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
{sub && <div className="text-xs text-zinc-500 mt-0.5">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
src/components/layout/nav-shell.tsx
Normal file
104
src/components/layout/nav-shell.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { usePathname } from "@/i18n/navigation";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { LanguageSwitcher } from "@/components/ui/language-switcher";
|
||||
|
||||
function NavBar() {
|
||||
const t = useTranslations("common");
|
||||
const { data: session } = useSession();
|
||||
const pathname = usePathname();
|
||||
const user = (session as any)?.platformUser;
|
||||
|
||||
const isLogin = pathname === "/login";
|
||||
if (isLogin) return null;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-border bg-surface-1/80 backdrop-blur-md">
|
||||
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-5">
|
||||
{/* Logo / brand */}
|
||||
<div className="flex items-center gap-6">
|
||||
<Link href="/dashboard" className="flex items-center gap-2.5 group">
|
||||
{/* Geometric mark */}
|
||||
<div className="relative h-7 w-7">
|
||||
<div className="absolute inset-0 rounded-md bg-accent/20 group-hover:bg-accent/30 transition-colors" />
|
||||
<div className="absolute inset-[3px] rounded-sm bg-accent" />
|
||||
</div>
|
||||
<span className="font-display text-base font-semibold tracking-tight text-text-primary">
|
||||
{t("appName")}
|
||||
</span>
|
||||
<span className="hidden sm:inline text-[11px] font-medium tracking-widest uppercase text-text-muted">
|
||||
{t("tagline")}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Nav links */}
|
||||
<nav className="hidden sm:flex items-center gap-1 ml-2">
|
||||
<NavLink href="/dashboard" active={pathname === "/dashboard"}>
|
||||
{t("dashboard")}
|
||||
</NavLink>
|
||||
{user?.isPlatform && (
|
||||
<NavLink href="/admin" active={pathname === "/admin"}>
|
||||
{t("admin")}
|
||||
</NavLink>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-4">
|
||||
{user && (
|
||||
<span className="hidden md:inline text-xs text-text-secondary font-mono">
|
||||
{user.orgName}
|
||||
</span>
|
||||
)}
|
||||
<LanguageSwitcher />
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||
className="text-xs font-medium text-text-secondary hover:text-error transition-colors cursor-pointer"
|
||||
>
|
||||
{t("logout")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLink({
|
||||
href,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
active: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={`
|
||||
px-3 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
${
|
||||
active
|
||||
? "bg-surface-3 text-text-primary"
|
||||
: "text-text-secondary hover:text-text-primary hover:bg-surface-2"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function NavShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SessionProvider>
|
||||
<NavBar />
|
||||
<main className="mx-auto max-w-6xl px-5 py-8">{children}</main>
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
196
src/components/tenants/TenantDetailClient.tsx
Normal file
196
src/components/tenants/TenantDetailClient.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { PACKAGE_CATALOG, type PackageDef } from "@/lib/packages";
|
||||
import { PhaseBadge } from "@/components/dashboard/InstanceStatus";
|
||||
import PackageCard from "@/components/packages/PackageCard";
|
||||
import WorkspaceEditor from "@/components/packages/WorkspaceEditor";
|
||||
|
||||
interface TenantCR {
|
||||
metadata: {
|
||||
name: string;
|
||||
creationTimestamp: string;
|
||||
resourceVersion: string;
|
||||
};
|
||||
spec: {
|
||||
agentName?: string;
|
||||
displayName?: string;
|
||||
packages?: string[];
|
||||
workspaceFiles?: { name: string; content: string }[];
|
||||
litellmTeamId?: string;
|
||||
};
|
||||
status?: {
|
||||
phase: string;
|
||||
conditions?: {
|
||||
type: string;
|
||||
status: string;
|
||||
message?: string;
|
||||
reason?: string;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
export default function TenantDetailClient() {
|
||||
const t = useTranslations("tenantDetail");
|
||||
const { name } = useParams<{ name: string }>();
|
||||
const [tenant, setTenant] = useState<TenantCR | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchTenant = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/tenants/${name}`);
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
setTenant(await res.json());
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [name]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTenant();
|
||||
}, [fetchTenant]);
|
||||
|
||||
async function handlePackageToggle(
|
||||
packageId: string,
|
||||
enable: boolean
|
||||
) {
|
||||
if (!tenant) return;
|
||||
|
||||
const currentPackages = tenant.spec.packages || [];
|
||||
const newPackages = enable
|
||||
? [...currentPackages, packageId]
|
||||
: currentPackages.filter((p) => p !== packageId);
|
||||
|
||||
const res = await fetch(`/api/tenants/${name}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ packages: newPackages }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Failed to update packages");
|
||||
}
|
||||
|
||||
// Refetch tenant state
|
||||
await fetchTenant();
|
||||
}
|
||||
|
||||
async function handleWorkspaceSave(
|
||||
files: { name: string; content: string }[]
|
||||
) {
|
||||
const res = await fetch(`/api/tenants/${name}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ workspaceFiles: files }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Failed to update workspace files");
|
||||
}
|
||||
|
||||
await fetchTenant();
|
||||
}
|
||||
|
||||
function getPackageStatus(
|
||||
pkgId: string
|
||||
): "pending" | "active" | "error" | undefined {
|
||||
if (!tenant?.status?.conditions) return undefined;
|
||||
const cond = tenant.status.conditions.find(
|
||||
(c) => c.type === `Package/${pkgId}`
|
||||
);
|
||||
if (!cond) return "pending";
|
||||
if (cond.status === "True") return "active";
|
||||
if (cond.status === "False") return "error";
|
||||
return "pending";
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4 animate-pulse">
|
||||
<div className="h-8 w-48 bg-zinc-800 rounded" />
|
||||
<div className="h-40 bg-zinc-900/50 border border-zinc-800 rounded-lg" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-28 bg-zinc-900/50 border border-zinc-800 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !tenant) {
|
||||
return (
|
||||
<div className="rounded-lg border border-red-900/30 bg-red-950/20 p-4 text-sm text-red-400">
|
||||
{error || t("notFound")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const enabledPackages = tenant.spec.packages || [];
|
||||
const workspaceFiles = tenant.spec.workspaceFiles || [
|
||||
{ name: "SOUL.md", content: "" },
|
||||
{ name: "AGENTS.md", content: "" },
|
||||
{ name: "TOOLS.md", content: "" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-zinc-100">
|
||||
{tenant.spec.displayName || name}
|
||||
</h1>
|
||||
{tenant.spec.agentName && (
|
||||
<p className="text-sm text-zinc-500 mt-0.5">
|
||||
{t("agent")}: {tenant.spec.agentName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<PhaseBadge phase={tenant.status?.phase || "Pending"} />
|
||||
</div>
|
||||
|
||||
{/* Packages */}
|
||||
<section>
|
||||
<h2 className="text-sm font-medium text-zinc-300 mb-3">
|
||||
{t("packages")}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{PACKAGE_CATALOG.map((pkg) => (
|
||||
<PackageCard
|
||||
key={pkg.id}
|
||||
pkg={pkg}
|
||||
enabled={enabledPackages.includes(pkg.id)}
|
||||
status={
|
||||
enabledPackages.includes(pkg.id)
|
||||
? getPackageStatus(pkg.id)
|
||||
: undefined
|
||||
}
|
||||
tenantName={name}
|
||||
onToggle={handlePackageToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Workspace files */}
|
||||
<section>
|
||||
<h2 className="text-sm font-medium text-zinc-300 mb-3">
|
||||
{t("workspaceFiles")}
|
||||
</h2>
|
||||
<WorkspaceEditor
|
||||
tenantName={name}
|
||||
files={workspaceFiles}
|
||||
onSave={handleWorkspaceSave}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
src/components/ui/card.tsx
Normal file
37
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
export function Card({
|
||||
children,
|
||||
className = "",
|
||||
interactive = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
interactive?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
rounded-xl border border-border bg-surface-1 p-6
|
||||
${interactive ? "card-interactive cursor-pointer" : ""}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<h3
|
||||
className={`text-xs font-semibold uppercase tracking-wider text-text-muted mb-3 ${className}`}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
91
src/components/ui/language-switcher.tsx
Normal file
91
src/components/ui/language-switcher.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "next-intl";
|
||||
import { useRouter, usePathname } from "@/i18n/navigation";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
|
||||
const LOCALE_LABELS: Record<string, string> = {
|
||||
de: "DE",
|
||||
fr: "FR",
|
||||
it: "IT",
|
||||
en: "EN",
|
||||
};
|
||||
|
||||
const LOCALE_NAMES: Record<string, string> = {
|
||||
de: "Deutsch",
|
||||
fr: "Français",
|
||||
it: "Italiano",
|
||||
en: "English",
|
||||
};
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, []);
|
||||
|
||||
function switchLocale(next: string) {
|
||||
setOpen(false);
|
||||
router.replace(pathname, { locale: next as any });
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="
|
||||
flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium
|
||||
text-text-secondary hover:text-text-primary hover:bg-surface-2
|
||||
transition-colors cursor-pointer
|
||||
"
|
||||
>
|
||||
<span className="font-mono">{LOCALE_LABELS[locale]}</span>
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform ${open ? "rotate-180" : ""}`}
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path d="M3 4.5L6 7.5L9 4.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 w-32 rounded-lg border border-border bg-surface-1 shadow-xl shadow-black/30 overflow-hidden z-50">
|
||||
{Object.entries(LOCALE_NAMES).map(([code, name]) => (
|
||||
<button
|
||||
key={code}
|
||||
onClick={() => switchLocale(code)}
|
||||
className={`
|
||||
w-full px-3 py-2 text-left text-xs transition-colors cursor-pointer
|
||||
flex items-center justify-between
|
||||
${
|
||||
code === locale
|
||||
? "bg-surface-3 text-accent font-medium"
|
||||
: "text-text-secondary hover:bg-surface-2 hover:text-text-primary"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span>{name}</span>
|
||||
<span className="font-mono text-text-muted">
|
||||
{LOCALE_LABELS[code]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
src/components/ui/status-badge.tsx
Normal file
29
src/components/ui/status-badge.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
const phaseStyles: Record<string, string> = {
|
||||
Running:
|
||||
"bg-success/10 text-success border-success/20",
|
||||
Provisioning:
|
||||
"bg-warning/10 text-warning border-warning/20",
|
||||
Pending:
|
||||
"bg-text-muted/10 text-text-secondary border-border",
|
||||
Error:
|
||||
"bg-error/10 text-error border-error/20",
|
||||
Deleting:
|
||||
"bg-text-muted/10 text-text-muted border-border",
|
||||
};
|
||||
|
||||
export function StatusBadge({ phase }: { phase: string }) {
|
||||
const style = phaseStyles[phase] ?? phaseStyles.Pending;
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${style}`}
|
||||
>
|
||||
{phase === "Running" && (
|
||||
<span className="status-pulse h-1.5 w-1.5 rounded-full bg-success" />
|
||||
)}
|
||||
{phase === "Provisioning" && (
|
||||
<span className="status-pulse h-1.5 w-1.5 rounded-full bg-warning" />
|
||||
)}
|
||||
{phase}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user