mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-03 02:11:48 +00:00
chore: remove comments
This commit is contained in:
parent
0348a69c51
commit
e1027134cd
12 changed files with 721 additions and 378 deletions
|
|
@ -1,13 +1,12 @@
|
|||
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
Brain,
|
||||
Cpu,
|
||||
RefreshCw,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import { BarChart3, Brain, Cpu, RefreshCw, TrendingUp } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { AnalyticsResponse, AnalyticsDailyEntry, AnalyticsModelEntry, AnalyticsSkillEntry } from "@/lib/api";
|
||||
import type {
|
||||
AnalyticsResponse,
|
||||
AnalyticsDailyEntry,
|
||||
AnalyticsModelEntry,
|
||||
AnalyticsSkillEntry,
|
||||
} from "@/lib/api";
|
||||
import { timeAgo } from "@/lib/utils";
|
||||
import { Button, Stats } from "@nous-research/ui";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
|
@ -43,16 +42,21 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
|||
const { t } = useI18n();
|
||||
if (daily.length === 0) return null;
|
||||
|
||||
const maxTokens = Math.max(...daily.map((d) => d.input_tokens + d.output_tokens), 1);
|
||||
const maxTokens = Math.max(
|
||||
...daily.map((d) => d.input_tokens + d.output_tokens),
|
||||
1,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">{t.analytics.dailyTokenUsage}</CardTitle>
|
||||
<CardTitle className="text-base">
|
||||
{t.analytics.dailyTokenUsage}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-2.5 w-2.5 bg-[#ffe6cb]" />
|
||||
{t.analytics.input}
|
||||
|
|
@ -64,47 +68,63 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
|||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-end gap-[2px]" style={{ height: CHART_HEIGHT_PX }}>
|
||||
<div
|
||||
className="flex items-end gap-[2px]"
|
||||
style={{ height: CHART_HEIGHT_PX }}
|
||||
>
|
||||
{daily.map((d) => {
|
||||
const total = d.input_tokens + d.output_tokens;
|
||||
const inputH = Math.round((d.input_tokens / maxTokens) * CHART_HEIGHT_PX);
|
||||
const outputH = Math.round((d.output_tokens / maxTokens) * CHART_HEIGHT_PX);
|
||||
const inputH = Math.round(
|
||||
(d.input_tokens / maxTokens) * CHART_HEIGHT_PX,
|
||||
);
|
||||
const outputH = Math.round(
|
||||
(d.output_tokens / maxTokens) * CHART_HEIGHT_PX,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={d.day}
|
||||
className="flex-1 min-w-0 group relative flex flex-col justify-end"
|
||||
style={{ height: CHART_HEIGHT_PX }}
|
||||
>
|
||||
{/* Tooltip */}
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block z-10 pointer-events-none">
|
||||
<div className="bg-card border border-border px-2.5 py-1.5 text-[10px] text-foreground shadow-lg whitespace-nowrap">
|
||||
<div className="font-medium">{formatDate(d.day)}</div>
|
||||
<div>{t.analytics.input}: {formatTokens(d.input_tokens)}</div>
|
||||
<div>{t.analytics.output}: {formatTokens(d.output_tokens)}</div>
|
||||
<div>{t.analytics.total}: {formatTokens(total)}</div>
|
||||
<div>
|
||||
{t.analytics.input}: {formatTokens(d.input_tokens)}
|
||||
</div>
|
||||
<div>
|
||||
{t.analytics.output}: {formatTokens(d.output_tokens)}
|
||||
</div>
|
||||
<div>
|
||||
{t.analytics.total}: {formatTokens(total)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Input bar */}
|
||||
|
||||
<div
|
||||
className="w-full bg-[#ffe6cb]/70"
|
||||
style={{ height: Math.max(inputH, total > 0 ? 1 : 0) }}
|
||||
/>
|
||||
{/* Output bar */}
|
||||
|
||||
<div
|
||||
className="w-full bg-emerald-500/70"
|
||||
style={{ height: Math.max(outputH, d.output_tokens > 0 ? 1 : 0) }}
|
||||
style={{
|
||||
height: Math.max(outputH, d.output_tokens > 0 ? 1 : 0),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* X-axis labels */}
|
||||
|
||||
<div className="flex justify-between mt-2 text-[10px] text-muted-foreground">
|
||||
<span>{daily.length > 0 ? formatDate(daily[0].day) : ""}</span>
|
||||
{daily.length > 2 && (
|
||||
<span>{formatDate(daily[Math.floor(daily.length / 2)].day)}</span>
|
||||
)}
|
||||
<span>{daily.length > 1 ? formatDate(daily[daily.length - 1].day) : ""}</span>
|
||||
<span>
|
||||
{daily.length > 1 ? formatDate(daily[daily.length - 1].day) : ""}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -122,7 +142,9 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
|||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">{t.analytics.dailyBreakdown}</CardTitle>
|
||||
<CardTitle className="text-base">
|
||||
{t.analytics.dailyBreakdown}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -130,23 +152,42 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
|||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-muted-foreground text-xs">
|
||||
<th className="text-left py-2 pr-4 font-medium">{t.analytics.date}</th>
|
||||
<th className="text-right py-2 px-4 font-medium">{t.sessions.title}</th>
|
||||
<th className="text-right py-2 px-4 font-medium">{t.analytics.input}</th>
|
||||
<th className="text-right py-2 pl-4 font-medium">{t.analytics.output}</th>
|
||||
<th className="text-left py-2 pr-4 font-medium">
|
||||
{t.analytics.date}
|
||||
</th>
|
||||
<th className="text-right py-2 px-4 font-medium">
|
||||
{t.sessions.title}
|
||||
</th>
|
||||
<th className="text-right py-2 px-4 font-medium">
|
||||
{t.analytics.input}
|
||||
</th>
|
||||
<th className="text-right py-2 pl-4 font-medium">
|
||||
{t.analytics.output}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((d) => {
|
||||
return (
|
||||
<tr key={d.day} className="border-b border-border/50 hover:bg-secondary/20 transition-colors">
|
||||
<td className="py-2 pr-4 font-medium">{formatDate(d.day)}</td>
|
||||
<td className="text-right py-2 px-4 text-muted-foreground">{d.sessions}</td>
|
||||
<tr
|
||||
key={d.day}
|
||||
className="border-b border-border/50 hover:bg-secondary/20 transition-colors"
|
||||
>
|
||||
<td className="py-2 pr-4 font-medium">
|
||||
{formatDate(d.day)}
|
||||
</td>
|
||||
<td className="text-right py-2 px-4 text-muted-foreground">
|
||||
{d.sessions}
|
||||
</td>
|
||||
<td className="text-right py-2 px-4">
|
||||
<span className="text-[#ffe6cb]">{formatTokens(d.input_tokens)}</span>
|
||||
<span className="text-[#ffe6cb]">
|
||||
{formatTokens(d.input_tokens)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-right py-2 pl-4">
|
||||
<span className="text-emerald-400">{formatTokens(d.output_tokens)}</span>
|
||||
<span className="text-emerald-400">
|
||||
{formatTokens(d.output_tokens)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
|
@ -164,7 +205,8 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
|
|||
if (models.length === 0) return null;
|
||||
|
||||
const sorted = [...models].sort(
|
||||
(a, b) => b.input_tokens + b.output_tokens - (a.input_tokens + a.output_tokens),
|
||||
(a, b) =>
|
||||
b.input_tokens + b.output_tokens - (a.input_tokens + a.output_tokens),
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -172,7 +214,9 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
|
|||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">{t.analytics.perModelBreakdown}</CardTitle>
|
||||
<CardTitle className="text-base">
|
||||
{t.analytics.perModelBreakdown}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -180,22 +224,37 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
|
|||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-muted-foreground text-xs">
|
||||
<th className="text-left py-2 pr-4 font-medium">{t.analytics.model}</th>
|
||||
<th className="text-right py-2 px-4 font-medium">{t.sessions.title}</th>
|
||||
<th className="text-right py-2 pl-4 font-medium">{t.analytics.tokens}</th>
|
||||
<th className="text-left py-2 pr-4 font-medium">
|
||||
{t.analytics.model}
|
||||
</th>
|
||||
<th className="text-right py-2 px-4 font-medium">
|
||||
{t.sessions.title}
|
||||
</th>
|
||||
<th className="text-right py-2 pl-4 font-medium">
|
||||
{t.analytics.tokens}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((m) => (
|
||||
<tr key={m.model} className="border-b border-border/50 hover:bg-secondary/20 transition-colors">
|
||||
<tr
|
||||
key={m.model}
|
||||
className="border-b border-border/50 hover:bg-secondary/20 transition-colors"
|
||||
>
|
||||
<td className="py-2 pr-4">
|
||||
<span className="font-mono-ui text-xs">{m.model}</span>
|
||||
</td>
|
||||
<td className="text-right py-2 px-4 text-muted-foreground">{m.sessions}</td>
|
||||
<td className="text-right py-2 px-4 text-muted-foreground">
|
||||
{m.sessions}
|
||||
</td>
|
||||
<td className="text-right py-2 pl-4">
|
||||
<span className="text-[#ffe6cb]">{formatTokens(m.input_tokens)}</span>
|
||||
<span className="text-[#ffe6cb]">
|
||||
{formatTokens(m.input_tokens)}
|
||||
</span>
|
||||
{" / "}
|
||||
<span className="text-emerald-400">{formatTokens(m.output_tokens)}</span>
|
||||
<span className="text-emerald-400">
|
||||
{formatTokens(m.output_tokens)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
@ -224,21 +283,38 @@ function SkillTable({ skills }: { skills: AnalyticsSkillEntry[] }) {
|
|||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-muted-foreground text-xs">
|
||||
<th className="text-left py-2 pr-4 font-medium">{t.analytics.skill}</th>
|
||||
<th className="text-right py-2 px-4 font-medium">{t.analytics.loads}</th>
|
||||
<th className="text-right py-2 px-4 font-medium">{t.analytics.edits}</th>
|
||||
<th className="text-right py-2 px-4 font-medium">{t.analytics.total}</th>
|
||||
<th className="text-right py-2 pl-4 font-medium">{t.analytics.lastUsed}</th>
|
||||
<th className="text-left py-2 pr-4 font-medium">
|
||||
{t.analytics.skill}
|
||||
</th>
|
||||
<th className="text-right py-2 px-4 font-medium">
|
||||
{t.analytics.loads}
|
||||
</th>
|
||||
<th className="text-right py-2 px-4 font-medium">
|
||||
{t.analytics.edits}
|
||||
</th>
|
||||
<th className="text-right py-2 px-4 font-medium">
|
||||
{t.analytics.total}
|
||||
</th>
|
||||
<th className="text-right py-2 pl-4 font-medium">
|
||||
{t.analytics.lastUsed}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{skills.map((skill) => (
|
||||
<tr key={skill.skill} className="border-b border-border/50 hover:bg-secondary/20 transition-colors">
|
||||
<tr
|
||||
key={skill.skill}
|
||||
className="border-b border-border/50 hover:bg-secondary/20 transition-colors"
|
||||
>
|
||||
<td className="py-2 pr-4">
|
||||
<span className="font-mono-ui text-xs">{skill.skill}</span>
|
||||
</td>
|
||||
<td className="text-right py-2 px-4 text-muted-foreground">{skill.view_count}</td>
|
||||
<td className="text-right py-2 px-4 text-muted-foreground">{skill.manage_count}</td>
|
||||
<td className="text-right py-2 px-4 text-muted-foreground">
|
||||
{skill.view_count}
|
||||
</td>
|
||||
<td className="text-right py-2 px-4 text-muted-foreground">
|
||||
{skill.manage_count}
|
||||
</td>
|
||||
<td className="text-right py-2 px-4">{skill.total_count}</td>
|
||||
<td className="text-right py-2 pl-4 text-muted-foreground">
|
||||
{skill.last_used_at ? timeAgo(skill.last_used_at) : "—"}
|
||||
|
|
@ -338,7 +414,6 @@ export default function AnalyticsPage() {
|
|||
|
||||
{data && (
|
||||
<>
|
||||
{/* Summary stats + bar chart side-by-side on lg+ */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardContent className="py-6">
|
||||
|
|
@ -377,24 +452,28 @@ export default function AnalyticsPage() {
|
|||
<TokenBarChart daily={data.daily} />
|
||||
</div>
|
||||
|
||||
{/* Tables */}
|
||||
<DailyTable daily={data.daily} />
|
||||
<ModelTable models={data.by_model} />
|
||||
<SkillTable skills={data.skills.top_skills} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{data && data.daily.length === 0 && data.by_model.length === 0 && data.skills.top_skills.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-12">
|
||||
<div className="flex flex-col items-center text-muted-foreground">
|
||||
<BarChart3 className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm font-medium">{t.analytics.noUsageData}</p>
|
||||
<p className="text-xs mt-1 text-muted-foreground/60">{t.analytics.startSession}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{data &&
|
||||
data.daily.length === 0 &&
|
||||
data.by_model.length === 0 &&
|
||||
data.skills.top_skills.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-12">
|
||||
<div className="flex flex-col items-center text-muted-foreground">
|
||||
<BarChart3 className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm font-medium">{t.analytics.noUsageData}</p>
|
||||
<p className="text-xs mt-1 text-muted-foreground/60">
|
||||
{t.analytics.startSession}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<PluginSlot name="analytics:bottom" />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -45,7 +45,10 @@ import { PluginSlot } from "@/plugins";
|
|||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const CATEGORY_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
const CATEGORY_ICONS: Record<
|
||||
string,
|
||||
React.ComponentType<{ className?: string }>
|
||||
> = {
|
||||
general: Settings,
|
||||
agent: Bot,
|
||||
terminal: Monitor,
|
||||
|
|
@ -63,7 +66,13 @@ const CATEGORY_ICONS: Record<string, React.ComponentType<{ className?: string }>
|
|||
auxiliary: Wrench,
|
||||
};
|
||||
|
||||
function CategoryIcon({ category, className }: { category: string; className?: string }) {
|
||||
function CategoryIcon({
|
||||
category,
|
||||
className,
|
||||
}: {
|
||||
category: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const Icon = CATEGORY_ICONS[category] ?? FileQuestion;
|
||||
return <Icon className={className ?? "h-4 w-4"} />;
|
||||
}
|
||||
|
|
@ -74,9 +83,14 @@ function CategoryIcon({ category, className }: { category: string; className?: s
|
|||
|
||||
export default function ConfigPage() {
|
||||
const [config, setConfig] = useState<Record<string, unknown> | null>(null);
|
||||
const [schema, setSchema] = useState<Record<string, Record<string, unknown>> | null>(null);
|
||||
const [schema, setSchema] = useState<Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
> | null>(null);
|
||||
const [categoryOrder, setCategoryOrder] = useState<string[]>([]);
|
||||
const [defaults, setDefaults] = useState<Record<string, unknown> | null>(null);
|
||||
const [defaults, setDefaults] = useState<Record<string, unknown> | null>(
|
||||
null,
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [yamlMode, setYamlMode] = useState(false);
|
||||
|
|
@ -124,7 +138,10 @@ export default function ConfigPage() {
|
|||
}
|
||||
|
||||
useEffect(() => {
|
||||
api.getConfig().then(setConfig).catch(() => {});
|
||||
api
|
||||
.getConfig()
|
||||
.then(setConfig)
|
||||
.catch(() => {});
|
||||
api
|
||||
.getSchema()
|
||||
.then((resp) => {
|
||||
|
|
@ -132,7 +149,10 @@ export default function ConfigPage() {
|
|||
setCategoryOrder(resp.category_order ?? []);
|
||||
})
|
||||
.catch(() => {});
|
||||
api.getDefaults().then(setDefaults).catch(() => {});
|
||||
api
|
||||
.getDefaults()
|
||||
.then(setDefaults)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Set active category when categories load
|
||||
|
|
@ -157,7 +177,11 @@ export default function ConfigPage() {
|
|||
/* ---- Categories ---- */
|
||||
const categories = useMemo(() => {
|
||||
if (!schema) return [];
|
||||
const allCats = [...new Set(Object.values(schema).map((s) => String(s.category ?? "general")))];
|
||||
const allCats = [
|
||||
...new Set(
|
||||
Object.values(schema).map((s) => String(s.category ?? "general")),
|
||||
),
|
||||
];
|
||||
const ordered = categoryOrder.filter((c) => allCats.includes(c));
|
||||
const extra = allCats.filter((c) => !categoryOrder.includes(c)).sort();
|
||||
return [...ordered, ...extra];
|
||||
|
|
@ -186,8 +210,12 @@ export default function ConfigPage() {
|
|||
return (
|
||||
key.toLowerCase().includes(lowerSearch) ||
|
||||
humanLabel.toLowerCase().includes(lowerSearch) ||
|
||||
String(s.category ?? "").toLowerCase().includes(lowerSearch) ||
|
||||
String(s.description ?? "").toLowerCase().includes(lowerSearch)
|
||||
String(s.category ?? "")
|
||||
.toLowerCase()
|
||||
.includes(lowerSearch) ||
|
||||
String(s.description ?? "")
|
||||
.toLowerCase()
|
||||
.includes(lowerSearch)
|
||||
);
|
||||
});
|
||||
}, [isSearching, lowerSearch, schema]);
|
||||
|
|
@ -196,7 +224,7 @@ export default function ConfigPage() {
|
|||
const activeFields = useMemo(() => {
|
||||
if (!schema || isSearching) return [];
|
||||
return Object.entries(schema).filter(
|
||||
([, s]) => String(s.category ?? "general") === activeCategory
|
||||
([, s]) => String(s.category ?? "general") === activeCategory,
|
||||
);
|
||||
}, [schema, activeCategory, isSearching]);
|
||||
|
||||
|
|
@ -219,7 +247,10 @@ export default function ConfigPage() {
|
|||
try {
|
||||
await api.saveConfigRaw(yamlText);
|
||||
showToast(t.config.yamlConfigSaved, "success");
|
||||
api.getConfig().then(setConfig).catch(() => {});
|
||||
api
|
||||
.getConfig()
|
||||
.then(setConfig)
|
||||
.catch(() => {});
|
||||
} catch (e) {
|
||||
showToast(`${t.config.failedToSaveYaml}: ${e}`, "error");
|
||||
} finally {
|
||||
|
|
@ -247,12 +278,17 @@ export default function ConfigPage() {
|
|||
next = setNestedValue(next, key, getNestedValue(defaults, key));
|
||||
}
|
||||
setConfig(next);
|
||||
showToast(t.config.resetScopeToast.replace("{scope}", scopeLabel), "success");
|
||||
showToast(
|
||||
t.config.resetScopeToast.replace("{scope}", scopeLabel),
|
||||
"success",
|
||||
);
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
if (!config) return;
|
||||
const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" });
|
||||
const blob = new Blob([JSON.stringify(config, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
|
|
@ -287,7 +323,10 @@ export default function ConfigPage() {
|
|||
}
|
||||
|
||||
/* ---- Render field list (shared between search & normal) ---- */
|
||||
const renderFields = (fields: [string, Record<string, unknown>][], showCategory = false) => {
|
||||
const renderFields = (
|
||||
fields: [string, Record<string, unknown>][],
|
||||
showCategory = false,
|
||||
) => {
|
||||
let lastSection = "";
|
||||
let lastCat = "";
|
||||
return fields.map(([key, s]) => {
|
||||
|
|
@ -295,7 +334,11 @@ export default function ConfigPage() {
|
|||
const section = parts.length > 1 ? parts[0] : "";
|
||||
const cat = String(s.category ?? "general");
|
||||
const showCatBadge = showCategory && cat !== lastCat;
|
||||
const showSection = !showCategory && section && section !== lastSection && section !== activeCategory;
|
||||
const showSection =
|
||||
!showCategory &&
|
||||
section &&
|
||||
section !== lastSection &&
|
||||
section !== activeCategory;
|
||||
lastSection = section;
|
||||
lastCat = cat;
|
||||
|
||||
|
|
@ -303,7 +346,10 @@ export default function ConfigPage() {
|
|||
<div key={key}>
|
||||
{showCatBadge && (
|
||||
<div className="flex items-center gap-2 pt-4 pb-2 first:pt-0">
|
||||
<CategoryIcon category={cat} className="h-4 w-4 text-muted-foreground" />
|
||||
<CategoryIcon
|
||||
category={cat}
|
||||
className="h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{prettyCategoryName(cat)}
|
||||
</span>
|
||||
|
|
@ -336,7 +382,6 @@ export default function ConfigPage() {
|
|||
<PluginSlot name="config:top" />
|
||||
<Toast toast={toast} />
|
||||
|
||||
{/* ═══════════════ Header Bar ═══════════════ */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
||||
|
|
@ -345,24 +390,52 @@ export default function ConfigPage() {
|
|||
</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button ghost size="icon" onClick={handleExport} title={t.config.exportConfig} aria-label={t.config.exportConfig}>
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
onClick={handleExport}
|
||||
title={t.config.exportConfig}
|
||||
aria-label={t.config.exportConfig}
|
||||
>
|
||||
<Download />
|
||||
</Button>
|
||||
<Button ghost size="icon" onClick={() => fileInputRef.current?.click()} title={t.config.importConfig} aria-label={t.config.importConfig}>
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title={t.config.importConfig}
|
||||
aria-label={t.config.importConfig}
|
||||
>
|
||||
<Upload />
|
||||
</Button>
|
||||
<input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImport} />
|
||||
{!yamlMode && (() => {
|
||||
const resetScopeLabel = isSearching
|
||||
? t.config.searchResults
|
||||
: prettyCategoryName(activeCategory);
|
||||
const resetTitle = t.config.resetScopeTooltip.replace("{scope}", resetScopeLabel);
|
||||
return (
|
||||
<Button ghost size="icon" onClick={handleReset} title={resetTitle} aria-label={resetTitle}>
|
||||
<RotateCcw />
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
onChange={handleImport}
|
||||
/>
|
||||
{!yamlMode &&
|
||||
(() => {
|
||||
const resetScopeLabel = isSearching
|
||||
? t.config.searchResults
|
||||
: prettyCategoryName(activeCategory);
|
||||
const resetTitle = t.config.resetScopeTooltip.replace(
|
||||
"{scope}",
|
||||
resetScopeLabel,
|
||||
);
|
||||
return (
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
onClick={handleReset}
|
||||
title={resetTitle}
|
||||
aria-label={resetTitle}
|
||||
>
|
||||
<RotateCcw />
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
|
||||
|
|
@ -375,7 +448,11 @@ export default function ConfigPage() {
|
|||
</Button>
|
||||
|
||||
{yamlMode ? (
|
||||
<Button onClick={handleYamlSave} disabled={yamlSaving} prefix={<Save />}>
|
||||
<Button
|
||||
onClick={handleYamlSave}
|
||||
disabled={yamlSaving}
|
||||
prefix={<Save />}
|
||||
>
|
||||
{yamlSaving ? t.common.saving : t.common.save}
|
||||
</Button>
|
||||
) : (
|
||||
|
|
@ -386,7 +463,6 @@ export default function ConfigPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════ YAML Mode ═══════════════ */}
|
||||
{yamlMode ? (
|
||||
<Card>
|
||||
<CardHeader className="py-3 px-4">
|
||||
|
|
@ -411,13 +487,10 @@ export default function ConfigPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
/* ═══════════════ Form Mode ═══════════════ */
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* ---- Filter panel ---- */}
|
||||
<aside aria-label={t.config.filters} className="sm:w-56 sm:shrink-0">
|
||||
<div className="sm:sticky sm:top-4">
|
||||
<div className="flex flex-col border border-border bg-muted/20">
|
||||
{/* Panel heading */}
|
||||
<div className="hidden sm:flex items-center gap-2 px-3 py-2 border-b border-border">
|
||||
<Filter className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="font-mondwest text-[0.65rem] tracking-[0.12em] uppercase text-muted-foreground">
|
||||
|
|
@ -425,12 +498,10 @@ export default function ConfigPage() {
|
|||
</span>
|
||||
</div>
|
||||
|
||||
{/* Sections heading (hidden on mobile since it becomes a horizontal scroll) */}
|
||||
<div className="hidden sm:block px-3 pt-2 pb-1 font-mondwest text-[0.6rem] tracking-[0.12em] uppercase text-muted-foreground/70">
|
||||
{t.config.sections}
|
||||
</div>
|
||||
|
||||
{/* Category nav — horizontal scroll on mobile, pill list on sm+ */}
|
||||
<div className="flex sm:flex-col gap-1 sm:gap-px p-2 sm:pt-1 overflow-x-auto sm:overflow-x-visible scrollbar-none sm:max-h-[calc(100vh-260px)] sm:overflow-y-auto">
|
||||
{categories.map((cat) => {
|
||||
const isActive = !isSearching && activeCategory === cat;
|
||||
|
|
@ -454,8 +525,13 @@ export default function ConfigPage() {
|
|||
}
|
||||
`}
|
||||
>
|
||||
<CategoryIcon category={cat} className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate">{prettyCategoryName(cat)}</span>
|
||||
<CategoryIcon
|
||||
category={cat}
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
/>
|
||||
<span className="flex-1 truncate">
|
||||
{prettyCategoryName(cat)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] tabular-nums ${
|
||||
isActive
|
||||
|
|
@ -473,10 +549,8 @@ export default function ConfigPage() {
|
|||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ---- Content ---- */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{isSearching ? (
|
||||
/* Search results */
|
||||
<Card>
|
||||
<CardHeader className="py-3 px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -485,7 +559,11 @@ export default function ConfigPage() {
|
|||
{t.config.searchResults}
|
||||
</CardTitle>
|
||||
<Badge tone="secondary" className="text-[10px]">
|
||||
{searchMatchedFields.length} {t.config.fields.replace("{s}", searchMatchedFields.length !== 1 ? "s" : "")}
|
||||
{searchMatchedFields.length}{" "}
|
||||
{t.config.fields.replace(
|
||||
"{s}",
|
||||
searchMatchedFields.length !== 1 ? "s" : "",
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
|
@ -505,11 +583,18 @@ export default function ConfigPage() {
|
|||
<CardHeader className="py-3 px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<CategoryIcon category={activeCategory} className="h-4 w-4" />
|
||||
<CategoryIcon
|
||||
category={activeCategory}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
{prettyCategoryName(activeCategory)}
|
||||
</CardTitle>
|
||||
<Badge tone="secondary" className="text-[10px]">
|
||||
{activeFields.length} {t.config.fields.replace("{s}", activeFields.length !== 1 ? "s" : "")}
|
||||
{activeFields.length}{" "}
|
||||
{t.config.fields.replace(
|
||||
"{s}",
|
||||
activeFields.length !== 1 ? "s" : "",
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,13 @@ import { useConfirmDelete } from "@/hooks/useConfirmDelete";
|
|||
import { useToast } from "@/hooks/useToast";
|
||||
import { OAuthProvidersCard } from "@/components/OAuthProvidersCard";
|
||||
import { Button } from "@nous-research/ui";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@nous-research/ui";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -36,25 +42,25 @@ import { PluginSlot } from "@/plugins";
|
|||
/** Map env-var key prefixes to a human-friendly provider name + ordering. */
|
||||
const PROVIDER_GROUPS: { prefix: string; name: string; priority: number }[] = [
|
||||
// Nous Portal first
|
||||
{ prefix: "NOUS_", name: "Nous Portal", priority: 0 },
|
||||
{ prefix: "NOUS_", name: "Nous Portal", priority: 0 },
|
||||
// Then alphabetical by display name
|
||||
{ prefix: "ANTHROPIC_", name: "Anthropic", priority: 1 },
|
||||
{ prefix: "DASHSCOPE_", name: "DashScope (Qwen)", priority: 2 },
|
||||
{ prefix: "HERMES_QWEN_", name: "DashScope (Qwen)", priority: 2 },
|
||||
{ prefix: "DEEPSEEK_", name: "DeepSeek", priority: 3 },
|
||||
{ prefix: "GOOGLE_", name: "Gemini", priority: 4 },
|
||||
{ prefix: "GEMINI_", name: "Gemini", priority: 4 },
|
||||
{ prefix: "GLM_", name: "GLM / Z.AI", priority: 5 },
|
||||
{ prefix: "ZAI_", name: "GLM / Z.AI", priority: 5 },
|
||||
{ prefix: "Z_AI_", name: "GLM / Z.AI", priority: 5 },
|
||||
{ prefix: "HF_", name: "Hugging Face", priority: 6 },
|
||||
{ prefix: "KIMI_", name: "Kimi / Moonshot", priority: 7 },
|
||||
{ prefix: "MINIMAX_CN_", name: "MiniMax (China)", priority: 9 },
|
||||
{ prefix: "MINIMAX_", name: "MiniMax", priority: 8 },
|
||||
{ prefix: "OPENCODE_GO_", name: "OpenCode Go", priority: 10 },
|
||||
{ prefix: "OPENCODE_ZEN_", name: "OpenCode Zen", priority: 11 },
|
||||
{ prefix: "OPENROUTER_", name: "OpenRouter", priority: 12 },
|
||||
{ prefix: "XIAOMI_", name: "Xiaomi MiMo", priority: 13 },
|
||||
{ prefix: "ANTHROPIC_", name: "Anthropic", priority: 1 },
|
||||
{ prefix: "DASHSCOPE_", name: "DashScope (Qwen)", priority: 2 },
|
||||
{ prefix: "HERMES_QWEN_", name: "DashScope (Qwen)", priority: 2 },
|
||||
{ prefix: "DEEPSEEK_", name: "DeepSeek", priority: 3 },
|
||||
{ prefix: "GOOGLE_", name: "Gemini", priority: 4 },
|
||||
{ prefix: "GEMINI_", name: "Gemini", priority: 4 },
|
||||
{ prefix: "GLM_", name: "GLM / Z.AI", priority: 5 },
|
||||
{ prefix: "ZAI_", name: "GLM / Z.AI", priority: 5 },
|
||||
{ prefix: "Z_AI_", name: "GLM / Z.AI", priority: 5 },
|
||||
{ prefix: "HF_", name: "Hugging Face", priority: 6 },
|
||||
{ prefix: "KIMI_", name: "Kimi / Moonshot", priority: 7 },
|
||||
{ prefix: "MINIMAX_CN_", name: "MiniMax (China)", priority: 9 },
|
||||
{ prefix: "MINIMAX_", name: "MiniMax", priority: 8 },
|
||||
{ prefix: "OPENCODE_GO_", name: "OpenCode Go", priority: 10 },
|
||||
{ prefix: "OPENCODE_ZEN_", name: "OpenCode Zen", priority: 11 },
|
||||
{ prefix: "OPENROUTER_", name: "OpenRouter", priority: 12 },
|
||||
{ prefix: "XIAOMI_", name: "Xiaomi MiMo", priority: 13 },
|
||||
];
|
||||
|
||||
function getProviderGroup(key: string): string {
|
||||
|
|
@ -117,25 +123,38 @@ function EnvVarRow({
|
|||
const { t } = useI18n();
|
||||
const isEditing = edits[varKey] !== undefined;
|
||||
const isRevealed = !!revealed[varKey];
|
||||
const displayValue = isRevealed ? revealed[varKey] : (info.redacted_value ?? "---");
|
||||
const displayValue = isRevealed
|
||||
? revealed[varKey]
|
||||
: (info.redacted_value ?? "---");
|
||||
|
||||
// Compact inline row for unset, non-editing keys (used inside provider groups)
|
||||
if (compact && !info.is_set && !isEditing) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-1.5 opacity-50 hover:opacity-100 transition-opacity">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="font-mono-ui text-[0.7rem] text-muted-foreground">{varKey}</span>
|
||||
<span className="text-[0.65rem] text-muted-foreground/60 truncate hidden sm:block">{info.description}</span>
|
||||
<span className="font-mono-ui text-[0.7rem] text-muted-foreground">
|
||||
{varKey}
|
||||
</span>
|
||||
<span className="text-[0.65rem] text-muted-foreground/60 truncate hidden sm:block">
|
||||
{info.description}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{info.url && (
|
||||
<a href={info.url} target="_blank" rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline">
|
||||
<a
|
||||
href={info.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"
|
||||
>
|
||||
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
)}
|
||||
<Button outlined prefix={<Pencil />}
|
||||
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}>
|
||||
<Button
|
||||
outlined
|
||||
prefix={<Pencil />}
|
||||
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}
|
||||
>
|
||||
{t.common.set}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -148,18 +167,29 @@ function EnvVarRow({
|
|||
return (
|
||||
<div className="flex items-center justify-between gap-3 border border-border/50 px-4 py-2.5 opacity-60 hover:opacity-100 transition-opacity">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Label className="font-mono-ui text-[0.7rem] text-muted-foreground">{varKey}</Label>
|
||||
<span className="text-[0.65rem] text-muted-foreground/60 truncate hidden sm:block">{info.description}</span>
|
||||
<Label className="font-mono-ui text-[0.7rem] text-muted-foreground">
|
||||
{varKey}
|
||||
</Label>
|
||||
<span className="text-[0.65rem] text-muted-foreground/60 truncate hidden sm:block">
|
||||
{info.description}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{info.url && (
|
||||
<a href={info.url} target="_blank" rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline">
|
||||
<a
|
||||
href={info.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"
|
||||
>
|
||||
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
)}
|
||||
<Button outlined prefix={<Pencil />}
|
||||
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}>
|
||||
<Button
|
||||
outlined
|
||||
prefix={<Pencil />}
|
||||
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}
|
||||
>
|
||||
{t.common.set}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -178,8 +208,12 @@ function EnvVarRow({
|
|||
</Badge>
|
||||
</div>
|
||||
{info.url && (
|
||||
<a href={info.url} target="_blank" rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline">
|
||||
<a
|
||||
href={info.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"
|
||||
>
|
||||
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
)}
|
||||
|
|
@ -190,35 +224,57 @@ function EnvVarRow({
|
|||
{info.tools.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{info.tools.map((tool) => (
|
||||
<Badge key={tool} tone="secondary" className="text-[0.6rem] py-0 px-1.5">{tool}</Badge>
|
||||
<Badge
|
||||
key={tool}
|
||||
tone="secondary"
|
||||
className="text-[0.6rem] py-0 px-1.5"
|
||||
>
|
||||
{tool}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEditing && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`flex-1 border border-border px-3 py-2 font-mono-ui text-xs ${
|
||||
isRevealed ? "bg-background text-foreground select-all" : "bg-muted/30 text-muted-foreground"
|
||||
}`}>
|
||||
<div
|
||||
className={`flex-1 border border-border px-3 py-2 font-mono-ui text-xs ${
|
||||
isRevealed
|
||||
? "bg-background text-foreground select-all"
|
||||
: "bg-muted/30 text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{info.is_set ? displayValue : "---"}
|
||||
</div>
|
||||
|
||||
{info.is_set && (
|
||||
<Button ghost size="icon" onClick={() => onReveal(varKey)}
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
onClick={() => onReveal(varKey)}
|
||||
title={isRevealed ? t.env.hideValue : t.env.showValue}
|
||||
aria-label={isRevealed ? `Hide ${varKey}` : `Reveal ${varKey}`}>
|
||||
aria-label={isRevealed ? `Hide ${varKey}` : `Reveal ${varKey}`}
|
||||
>
|
||||
{isRevealed ? <EyeOff /> : <Eye />}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button outlined prefix={<Pencil />}
|
||||
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}>
|
||||
<Button
|
||||
outlined
|
||||
prefix={<Pencil />}
|
||||
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}
|
||||
>
|
||||
{info.is_set ? t.common.replace : t.common.set}
|
||||
</Button>
|
||||
|
||||
{info.is_set && (
|
||||
<Button outlined destructive prefix={<Trash2 />}
|
||||
onClick={() => onClear(varKey)} disabled={saving === varKey || clearDialogOpen}>
|
||||
<Button
|
||||
outlined
|
||||
destructive
|
||||
prefix={<Trash2 />}
|
||||
onClick={() => onClear(varKey)}
|
||||
disabled={saving === varKey || clearDialogOpen}
|
||||
>
|
||||
{saving === varKey ? "..." : t.common.clear}
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -227,12 +283,28 @@ function EnvVarRow({
|
|||
|
||||
{isEditing && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input autoFocus type="text" value={edits[varKey]}
|
||||
onChange={(e) => setEdits((prev) => ({ ...prev, [varKey]: e.target.value }))}
|
||||
placeholder={info.is_set ? t.env.replaceCurrentValue.replace("{preview}", info.redacted_value ?? "---") : t.env.enterValue}
|
||||
className="flex-1 font-mono-ui text-xs" />
|
||||
<Button onClick={() => onSave(varKey)} prefix={<Save />}
|
||||
disabled={saving === varKey || !edits[varKey]}>
|
||||
<Input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={edits[varKey]}
|
||||
onChange={(e) =>
|
||||
setEdits((prev) => ({ ...prev, [varKey]: e.target.value }))
|
||||
}
|
||||
placeholder={
|
||||
info.is_set
|
||||
? t.env.replaceCurrentValue.replace(
|
||||
"{preview}",
|
||||
info.redacted_value ?? "---",
|
||||
)
|
||||
: t.env.enterValue
|
||||
}
|
||||
className="flex-1 font-mono-ui text-xs"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => onSave(varKey)}
|
||||
prefix={<Save />}
|
||||
disabled={saving === varKey || !edits[varKey]}
|
||||
>
|
||||
{saving === varKey ? "..." : t.common.save}
|
||||
</Button>
|
||||
<Button outlined prefix={<X />} onClick={() => onCancelEdit(varKey)}>
|
||||
|
|
@ -275,11 +347,20 @@ function ProviderGroupCard({
|
|||
const { t } = useI18n();
|
||||
|
||||
// Separate API keys from base URLs and other settings
|
||||
const apiKeys = group.entries.filter(([k]) => k.endsWith("_API_KEY") || k.endsWith("_TOKEN"));
|
||||
const apiKeys = group.entries.filter(
|
||||
([k]) => k.endsWith("_API_KEY") || k.endsWith("_TOKEN"),
|
||||
);
|
||||
const baseUrls = group.entries.filter(([k]) => k.endsWith("_BASE_URL"));
|
||||
const other = group.entries.filter(([k]) => !k.endsWith("_API_KEY") && !k.endsWith("_TOKEN") && !k.endsWith("_BASE_URL"));
|
||||
const other = group.entries.filter(
|
||||
([k]) =>
|
||||
!k.endsWith("_API_KEY") &&
|
||||
!k.endsWith("_TOKEN") &&
|
||||
!k.endsWith("_BASE_URL"),
|
||||
);
|
||||
const hasAnyConfigured = group.entries.some(([, info]) => info.is_set);
|
||||
const configuredCount = group.entries.filter(([, info]) => info.is_set).length;
|
||||
const configuredCount = group.entries.filter(
|
||||
([, info]) => info.is_set,
|
||||
).length;
|
||||
|
||||
// Get a representative URL for "Get key" link
|
||||
const keyUrl = apiKeys.find(([, info]) => info.url)?.[1]?.url ?? null;
|
||||
|
|
@ -293,8 +374,14 @@ function ProviderGroupCard({
|
|||
className="flex w-full items-center justify-between gap-3 px-4 py-3 cursor-pointer hover:bg-primary/5 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{expanded ? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" /> : <ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />}
|
||||
<span className="font-semibold text-sm tracking-wide">{group.name === "Other" ? t.common.other : group.name}</span>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="font-semibold text-sm tracking-wide">
|
||||
{group.name === "Other" ? t.common.other : group.name}
|
||||
</span>
|
||||
{hasAnyConfigured && (
|
||||
<Badge tone="success" className="text-[0.6rem]">
|
||||
{configuredCount} {t.common.set.toLowerCase()}
|
||||
|
|
@ -303,45 +390,76 @@ function ProviderGroupCard({
|
|||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{keyUrl && (
|
||||
<a href={keyUrl} target="_blank" rel="noreferrer"
|
||||
<a
|
||||
href={keyUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
)}
|
||||
<span className="text-[0.65rem] text-muted-foreground/60">
|
||||
{t.env.keysCount.replace("{count}", String(group.entries.length)).replace("{s}", group.entries.length !== 1 ? "s" : "")}
|
||||
{t.env.keysCount
|
||||
.replace("{count}", String(group.entries.length))
|
||||
.replace("{s}", group.entries.length !== 1 ? "s" : "")}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Expanded content */}
|
||||
{expanded && (
|
||||
<div className="border-t border-border px-4 py-3 grid gap-2">
|
||||
{/* API keys first (most important) */}
|
||||
{apiKeys.map(([key, info]) => (
|
||||
<EnvVarRow
|
||||
key={key} varKey={key} info={info} compact
|
||||
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
|
||||
key={key}
|
||||
varKey={key}
|
||||
info={info}
|
||||
compact
|
||||
edits={edits}
|
||||
setEdits={setEdits}
|
||||
revealed={revealed}
|
||||
saving={saving}
|
||||
onSave={onSave}
|
||||
onClear={onClear}
|
||||
onReveal={onReveal}
|
||||
onCancelEdit={onCancelEdit}
|
||||
clearDialogOpen={clearDialogOpen}
|
||||
/>
|
||||
))}
|
||||
{/* Base URLs (secondary) */}
|
||||
|
||||
{baseUrls.map(([key, info]) => (
|
||||
<EnvVarRow
|
||||
key={key} varKey={key} info={info} compact
|
||||
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
|
||||
key={key}
|
||||
varKey={key}
|
||||
info={info}
|
||||
compact
|
||||
edits={edits}
|
||||
setEdits={setEdits}
|
||||
revealed={revealed}
|
||||
saving={saving}
|
||||
onSave={onSave}
|
||||
onClear={onClear}
|
||||
onReveal={onReveal}
|
||||
onCancelEdit={onCancelEdit}
|
||||
clearDialogOpen={clearDialogOpen}
|
||||
/>
|
||||
))}
|
||||
{/* Anything else */}
|
||||
|
||||
{other.map(([key, info]) => (
|
||||
<EnvVarRow
|
||||
key={key} varKey={key} info={info} compact
|
||||
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
|
||||
key={key}
|
||||
varKey={key}
|
||||
info={info}
|
||||
compact
|
||||
edits={edits}
|
||||
setEdits={setEdits}
|
||||
revealed={revealed}
|
||||
saving={saving}
|
||||
onSave={onSave}
|
||||
onClear={onClear}
|
||||
onReveal={onReveal}
|
||||
onCancelEdit={onCancelEdit}
|
||||
clearDialogOpen={clearDialogOpen}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -365,7 +483,10 @@ export default function EnvPage() {
|
|||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
api.getEnvVars().then(setVars).catch(() => {});
|
||||
api
|
||||
.getEnvVars()
|
||||
.then(setVars)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleSave = async (key: string) => {
|
||||
|
|
@ -378,12 +499,24 @@ export default function EnvPage() {
|
|||
prev
|
||||
? {
|
||||
...prev,
|
||||
[key]: { ...prev[key], is_set: true, redacted_value: value.slice(0, 4) + "..." + value.slice(-4) },
|
||||
[key]: {
|
||||
...prev[key],
|
||||
is_set: true,
|
||||
redacted_value: value.slice(0, 4) + "..." + value.slice(-4),
|
||||
},
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
||||
setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
||||
setEdits((prev) => {
|
||||
const n = { ...prev };
|
||||
delete n[key];
|
||||
return n;
|
||||
});
|
||||
setRevealed((prev) => {
|
||||
const n = { ...prev };
|
||||
delete n[key];
|
||||
return n;
|
||||
});
|
||||
showToast(`${key} ${t.common.save.toLowerCase()}d`, "success");
|
||||
} catch (e) {
|
||||
showToast(`${t.config.failedToSave} ${key}: ${e}`, "error");
|
||||
|
|
@ -400,11 +533,22 @@ export default function EnvPage() {
|
|||
await api.deleteEnvVar(key);
|
||||
setVars((prev) =>
|
||||
prev
|
||||
? { ...prev, [key]: { ...prev[key], is_set: false, redacted_value: null } }
|
||||
? {
|
||||
...prev,
|
||||
[key]: { ...prev[key], is_set: false, redacted_value: null },
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
||||
setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
||||
setEdits((prev) => {
|
||||
const n = { ...prev };
|
||||
delete n[key];
|
||||
return n;
|
||||
});
|
||||
setRevealed((prev) => {
|
||||
const n = { ...prev };
|
||||
delete n[key];
|
||||
return n;
|
||||
});
|
||||
showToast(`${key} ${t.common.removed}`, "success");
|
||||
} catch (e) {
|
||||
showToast(`${t.common.failedToRemove} ${key}: ${e}`, "error");
|
||||
|
|
@ -419,7 +563,11 @@ export default function EnvPage() {
|
|||
|
||||
const handleReveal = async (key: string) => {
|
||||
if (revealed[key]) {
|
||||
setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
||||
setRevealed((prev) => {
|
||||
const n = { ...prev };
|
||||
delete n[key];
|
||||
return n;
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
|
@ -431,7 +579,11 @@ export default function EnvPage() {
|
|||
};
|
||||
|
||||
const cancelEdit = (key: string) => {
|
||||
setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
||||
setEdits((prev) => {
|
||||
const n = { ...prev };
|
||||
delete n[key];
|
||||
return n;
|
||||
});
|
||||
};
|
||||
|
||||
/* ---- Build provider groups ---- */
|
||||
|
|
@ -439,7 +591,8 @@ export default function EnvPage() {
|
|||
if (!vars) return { providerGroups: [], nonProviderGrouped: [] };
|
||||
|
||||
const providerEntries = Object.entries(vars).filter(
|
||||
([, info]) => info.category === "provider" && (showAdvanced || !info.advanced),
|
||||
([, info]) =>
|
||||
info.category === "provider" && (showAdvanced || !info.advanced),
|
||||
);
|
||||
|
||||
// Group by provider
|
||||
|
|
@ -498,9 +651,7 @@ export default function EnvPage() {
|
|||
|
||||
const pendingClearKey = keyClear.pendingId;
|
||||
const pendingKeyDescription =
|
||||
pendingClearKey && vars
|
||||
? vars[pendingClearKey]?.description
|
||||
: undefined;
|
||||
pendingClearKey && vars ? vars[pendingClearKey]?.description : undefined;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
|
|
@ -534,13 +685,11 @@ export default function EnvPage() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════ OAuth Logins ══ */}
|
||||
<OAuthProvidersCard
|
||||
onError={(msg) => showToast(msg, "error")}
|
||||
onSuccess={(msg) => showToast(msg, "success")}
|
||||
/>
|
||||
|
||||
{/* ═══════════════ LLM Providers (grouped) ═══════════════ */}
|
||||
<Card>
|
||||
<CardHeader className="border-b border-border bg-card">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -548,7 +697,9 @@ export default function EnvPage() {
|
|||
<CardTitle className="text-base">{t.env.llmProviders}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{t.env.providersConfigured.replace("{configured}", String(configuredProviders)).replace("{total}", String(totalProviders))}
|
||||
{t.env.providersConfigured
|
||||
.replace("{configured}", String(configuredProviders))
|
||||
.replace("{total}", String(totalProviders))}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
|
|
@ -557,53 +708,82 @@ export default function EnvPage() {
|
|||
<ProviderGroupCard
|
||||
key={group.name}
|
||||
group={group}
|
||||
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||
onSave={handleSave} onClear={keyClear.requestDelete} onReveal={handleReveal} onCancelEdit={cancelEdit}
|
||||
edits={edits}
|
||||
setEdits={setEdits}
|
||||
revealed={revealed}
|
||||
saving={saving}
|
||||
onSave={handleSave}
|
||||
onClear={keyClear.requestDelete}
|
||||
onReveal={handleReveal}
|
||||
onCancelEdit={cancelEdit}
|
||||
clearDialogOpen={keyClear.isOpen}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ═══════════════ Other categories (flat) ═══════════════ */}
|
||||
{nonProviderGrouped.map(({ label, icon: Icon, setEntries, unsetEntries, totalEntries, category }) => {
|
||||
if (totalEntries === 0) return null;
|
||||
{nonProviderGrouped.map(
|
||||
({
|
||||
label,
|
||||
icon: Icon,
|
||||
setEntries,
|
||||
unsetEntries,
|
||||
totalEntries,
|
||||
category,
|
||||
}) => {
|
||||
if (totalEntries === 0) return null;
|
||||
|
||||
return (
|
||||
<Card key={category}>
|
||||
<CardHeader className="border-b border-border bg-card">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">{label}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{setEntries.length} {t.common.of} {totalEntries} {t.common.configured}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
return (
|
||||
<Card key={category}>
|
||||
<CardHeader className="border-b border-border bg-card">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">{label}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{setEntries.length} {t.common.of} {totalEntries}{" "}
|
||||
{t.common.configured}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid gap-3 pt-4">
|
||||
{setEntries.map(([key, info]) => (
|
||||
<EnvVarRow
|
||||
key={key} varKey={key} info={info}
|
||||
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||
onSave={handleSave} onClear={keyClear.requestDelete} onReveal={handleReveal} onCancelEdit={cancelEdit}
|
||||
clearDialogOpen={keyClear.isOpen}
|
||||
/>
|
||||
))}
|
||||
<CardContent className="grid gap-3 pt-4">
|
||||
{setEntries.map(([key, info]) => (
|
||||
<EnvVarRow
|
||||
key={key}
|
||||
varKey={key}
|
||||
info={info}
|
||||
edits={edits}
|
||||
setEdits={setEdits}
|
||||
revealed={revealed}
|
||||
saving={saving}
|
||||
onSave={handleSave}
|
||||
onClear={keyClear.requestDelete}
|
||||
onReveal={handleReveal}
|
||||
onCancelEdit={cancelEdit}
|
||||
clearDialogOpen={keyClear.isOpen}
|
||||
/>
|
||||
))}
|
||||
|
||||
{unsetEntries.length > 0 && (
|
||||
<CollapsibleUnset
|
||||
category={category}
|
||||
unsetEntries={unsetEntries}
|
||||
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||
onSave={handleSave} onClear={keyClear.requestDelete} onReveal={handleReveal} onCancelEdit={cancelEdit}
|
||||
clearDialogOpen={keyClear.isOpen}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
{unsetEntries.length > 0 && (
|
||||
<CollapsibleUnset
|
||||
category={category}
|
||||
unsetEntries={unsetEntries}
|
||||
edits={edits}
|
||||
setEdits={setEdits}
|
||||
revealed={revealed}
|
||||
saving={saving}
|
||||
onSave={handleSave}
|
||||
onClear={keyClear.requestDelete}
|
||||
onReveal={handleReveal}
|
||||
onCancelEdit={cancelEdit}
|
||||
clearDialogOpen={keyClear.isOpen}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
)}
|
||||
<PluginSlot name="env:bottom" />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -648,20 +828,33 @@ function CollapsibleUnset({
|
|||
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer pt-1"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
>
|
||||
{collapsed
|
||||
? <ChevronRight className="h-3 w-3" />
|
||||
: <ChevronDown className="h-3 w-3" />}
|
||||
<span>{t.env.notConfigured.replace("{count}", String(unsetEntries.length))}</span>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
<span>
|
||||
{t.env.notConfigured.replace("{count}", String(unsetEntries.length))}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{!collapsed && unsetEntries.map(([key, info]) => (
|
||||
<EnvVarRow
|
||||
key={key} varKey={key} info={info}
|
||||
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
|
||||
clearDialogOpen={clearDialogOpen}
|
||||
/>
|
||||
))}
|
||||
{!collapsed &&
|
||||
unsetEntries.map(([key, info]) => (
|
||||
<EnvVarRow
|
||||
key={key}
|
||||
varKey={key}
|
||||
info={info}
|
||||
edits={edits}
|
||||
setEdits={setEdits}
|
||||
revealed={revealed}
|
||||
saving={saving}
|
||||
onSave={onSave}
|
||||
onClear={onClear}
|
||||
onReveal={onReveal}
|
||||
onCancelEdit={onCancelEdit}
|
||||
clearDialogOpen={clearDialogOpen}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import { useEffect, useLayoutEffect, useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { FileText, RefreshCw } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { Button } from "@nous-research/ui";
|
||||
|
|
@ -141,18 +147,25 @@ export default function LogsPage() {
|
|||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PluginSlot name="logs:top" />
|
||||
{/* ═══════════════ Filter toolbar ═══════════════ */}
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label={t.logs.title}
|
||||
className="flex flex-wrap items-center gap-x-6 gap-y-2"
|
||||
>
|
||||
<FilterGroup label={t.logs.file}>
|
||||
<Segmented value={file} onChange={setFile} options={toOptions(FILES)} />
|
||||
<Segmented
|
||||
value={file}
|
||||
onChange={setFile}
|
||||
options={toOptions(FILES)}
|
||||
/>
|
||||
</FilterGroup>
|
||||
|
||||
<FilterGroup label={t.logs.level}>
|
||||
<Segmented value={level} onChange={setLevel} options={toOptions(LEVELS)} />
|
||||
<Segmented
|
||||
value={level}
|
||||
onChange={setLevel}
|
||||
options={toOptions(LEVELS)}
|
||||
/>
|
||||
</FilterGroup>
|
||||
|
||||
<FilterGroup label={t.logs.component}>
|
||||
|
|
@ -177,7 +190,6 @@ export default function LogsPage() {
|
|||
</FilterGroup>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════ Log viewer ═══════════════ */}
|
||||
<Card>
|
||||
<CardHeader className="py-3 px-4">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -497,7 +497,10 @@ export default function SessionsPage() {
|
|||
|
||||
useEffect(() => {
|
||||
const loadOverview = () => {
|
||||
api.getStatus().then(setStatus).catch(() => {});
|
||||
api
|
||||
.getStatus()
|
||||
.then(setStatus)
|
||||
.catch(() => {});
|
||||
api
|
||||
.getSessions(50)
|
||||
.then((r) => setOverviewSessions(r.sessions))
|
||||
|
|
@ -551,7 +554,12 @@ export default function SessionsPage() {
|
|||
throw new Error("delete failed");
|
||||
}
|
||||
},
|
||||
[expandedId, showToast, t.sessions.sessionDeleted, t.sessions.failedToDelete],
|
||||
[
|
||||
expandedId,
|
||||
showToast,
|
||||
t.sessions.sessionDeleted,
|
||||
t.sessions.failedToDelete,
|
||||
],
|
||||
),
|
||||
});
|
||||
|
||||
|
|
@ -800,7 +808,6 @@ export default function SessionsPage() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination — hidden during search */}
|
||||
{!searchResults && total > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -221,15 +221,7 @@ export default function SkillsPage() {
|
|||
setAfterTitle(null);
|
||||
setEnd(null);
|
||||
};
|
||||
}, [
|
||||
enabledCount,
|
||||
loading,
|
||||
search,
|
||||
setAfterTitle,
|
||||
setEnd,
|
||||
skills.length,
|
||||
t,
|
||||
]);
|
||||
}, [enabledCount, loading, search, setAfterTitle, setEnd, skills.length, t]);
|
||||
|
||||
const filteredToolsets = useMemo(() => {
|
||||
return toolsets.filter(
|
||||
|
|
@ -255,13 +247,8 @@ export default function SkillsPage() {
|
|||
<PluginSlot name="skills:top" />
|
||||
<Toast toast={toast} />
|
||||
|
||||
{/* ═══════════════ Filter panel + Content ═══════════════ */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
|
||||
{/* ---- Filter panel ---- */}
|
||||
<aside
|
||||
aria-label={t.skills.title}
|
||||
className="sm:w-56 sm:shrink-0"
|
||||
>
|
||||
<aside aria-label={t.skills.title} className="sm:w-56 sm:shrink-0">
|
||||
<div className="sm:sticky sm:top-0">
|
||||
<div
|
||||
className={`
|
||||
|
|
@ -269,7 +256,6 @@ export default function SkillsPage() {
|
|||
border border-border bg-muted/20
|
||||
`}
|
||||
>
|
||||
{/* Filter heading */}
|
||||
<div className="hidden sm:flex items-center gap-2 px-3 py-2 border-b border-border">
|
||||
<Filter className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="font-mondwest text-[0.65rem] tracking-[0.12em] uppercase text-muted-foreground">
|
||||
|
|
@ -277,7 +263,6 @@ export default function SkillsPage() {
|
|||
</span>
|
||||
</div>
|
||||
|
||||
{/* View switch (Skills / Toolsets) */}
|
||||
<div className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-x-visible scrollbar-none p-2">
|
||||
<PanelItem
|
||||
icon={Package}
|
||||
|
|
@ -300,24 +285,25 @@ export default function SkillsPage() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Category sub-filters (only for Skills view) */}
|
||||
{view === "skills" && !isSearching && allCategories.length > 0 && (
|
||||
<div className="hidden sm:flex flex-col border-t border-border">
|
||||
<div className="px-3 pt-2 pb-1 font-mondwest text-[0.6rem] tracking-[0.12em] uppercase text-muted-foreground/70">
|
||||
{t.skills.categories}
|
||||
</div>
|
||||
<div className="flex flex-col p-2 pt-1 gap-px max-h-[calc(100vh-340px)] overflow-y-auto">
|
||||
{allCategories.map(({ key, name, count }) => {
|
||||
const isActive = activeCategory === key;
|
||||
{view === "skills" &&
|
||||
!isSearching &&
|
||||
allCategories.length > 0 && (
|
||||
<div className="hidden sm:flex flex-col border-t border-border">
|
||||
<div className="px-3 pt-2 pb-1 font-mondwest text-[0.6rem] tracking-[0.12em] uppercase text-muted-foreground/70">
|
||||
{t.skills.categories}
|
||||
</div>
|
||||
<div className="flex flex-col p-2 pt-1 gap-px max-h-[calc(100vh-340px)] overflow-y-auto">
|
||||
{allCategories.map(({ key, name, count }) => {
|
||||
const isActive = activeCategory === key;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setActiveCategory(isActive ? null : key)
|
||||
}
|
||||
className={`
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setActiveCategory(isActive ? null : key)
|
||||
}
|
||||
className={`
|
||||
group flex items-center gap-2 px-2 py-1
|
||||
rounded-sm text-left text-[11px] cursor-pointer
|
||||
transition-colors
|
||||
|
|
@ -327,31 +313,29 @@ export default function SkillsPage() {
|
|||
: "text-muted-foreground hover:text-foreground hover:bg-foreground/5"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span className="flex-1 truncate">{name}</span>
|
||||
<span
|
||||
className={`text-[10px] tabular-nums ${
|
||||
isActive
|
||||
? "text-foreground/60"
|
||||
: "text-muted-foreground/50"
|
||||
}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<span className="flex-1 truncate">{name}</span>
|
||||
<span
|
||||
className={`text-[10px] tabular-nums ${
|
||||
isActive
|
||||
? "text-foreground/60"
|
||||
: "text-muted-foreground/50"
|
||||
}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ---- Content ---- */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{isSearching ? (
|
||||
/* Search results */
|
||||
<Card>
|
||||
<CardHeader className="py-3 px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue