Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4d91052
feat(configs): lazy-load config versions on expand & extract inline S…
Ayush8923 Mar 27, 2026
78c33cb
feat(configs): lazy-load config versions on expand & extract inline S…
Ayush8923 Mar 27, 2026
42ad650
fix(*): linting and js comment
Ayush8923 Mar 27, 2026
3274e9d
fix(*): remove the unused files
Ayush8923 Mar 27, 2026
9a2da8f
fix(*): added the infinite scroll hook and update the icons
Ayush8923 Mar 27, 2026
f12d2d6
fix(*): update the constants
Ayush8923 Mar 27, 2026
e1b80be
fix(*): update imports
Ayush8923 Mar 27, 2026
395fabc
fix(*): remove the unused js comment
Ayush8923 Mar 27, 2026
bee1fc3
fix(*): update the tailwind style
Ayush8923 Mar 27, 2026
0c1d83c
fix(*): fix the alignment
Ayush8923 Mar 27, 2026
7a5b11f
fix(*): refactoring loader component
Ayush8923 Mar 27, 2026
133bdc4
feat(*): change grouping name routes->main
Ayush8923 Mar 27, 2026
8a38504
fix(*): formatting
Ayush8923 Mar 27, 2026
034efc3
feat(*): document pagination params & document folder restructuring &…
Ayush8923 Mar 29, 2026
8df6880
fix(*): added the reusable component
Ayush8923 Mar 29, 2026
7cc670d
fix(*): some updates and cleanups
Ayush8923 Mar 29, 2026
a79f4f4
fix(*): create the own modal component and cleanups
Ayush8923 Mar 29, 2026
e60b32f
fix(*): Remove unwanted types
Ayush8923 Mar 29, 2026
86daedc
fix(*): Remove unwanted types
Ayush8923 Mar 29, 2026
f1032ca
fix(*): added the quers params in the api endpoint
Ayush8923 Mar 30, 2026
4936b8b
Feat/document pagination params (#98)
Ayush8923 Mar 30, 2026
7ff52af
Change grouping name `routes` -> `main` (#97)
Ayush8923 Mar 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
385 changes: 385 additions & 0 deletions app/(main)/configurations/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,385 @@
/**
* Config Library: View and manage configs with quick actions (edit/use),
* showing usage count, and lazily loading version details on selection.
*/

"use client";

import { useState, useEffect, useCallback, useMemo } from "react";
import { useRouter } from "next/navigation";
import Sidebar from "@/app/components/Sidebar";
import PageHeader from "@/app/components/PageHeader";
import { colors } from "@/app/lib/colors";
import { usePaginatedList } from "@/app/hooks/usePaginatedList";
import { useInfiniteScroll } from "@/app/hooks/useInfiniteScroll";
import ConfigCard from "@/app/components/ConfigCard";
import Loader, { LoaderBox } from "@/app/components/Loader";
import { EvalJob } from "@/app/components/types";
import {
ConfigPublic,
ConfigVersionItems,
ConfigVersionResponse,
SavedConfig,
} from "@/app/lib/types/configs";
import {
configState,
pendingVersionLoads,
pendingSingleVersionLoads,
} from "@/app/lib/store/configStore";
import { flattenConfigVersion } from "@/app/lib/utils";
import {
SearchIcon,
RefreshIcon,
PlusIcon,
WarningTriangleIcon,
GearIcon,
} from "@/app/components/icons";
import { useAuth } from "@/app/lib/context/AuthContext";
import { useApp } from "@/app/lib/context/AppContext";
import { apiFetch } from "@/app/lib/apiClient";

const SEARCH_DEBOUNCE_MS = 350;

export default function ConfigLibraryPage() {
const router = useRouter();
const [evaluationCounts, setEvaluationCounts] = useState<
Record<string, number>
>({});
const { sidebarCollapsed } = useApp();
const { activeKey } = useAuth();
const apiKey = activeKey?.key;
const [searchInput, setSearchInput] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
const [columnCount, setColumnCount] = useState(3);
const {
items: configs,
isLoading,
isLoadingMore,
hasMore,
error,
loadMore,
refetch,
} = usePaginatedList<ConfigPublic>({
endpoint: "/api/configs",
query: debouncedQuery,
});
const scrollRef = useInfiniteScroll({
onLoadMore: loadMore,
hasMore,
isLoading: isLoading || isLoadingMore,
});

// Responsive column count (matches Tailwind lg/xl breakpoints)
useEffect(() => {
const update = () => {
if (window.innerWidth >= 1280) setColumnCount(3);
else if (window.innerWidth >= 1024) setColumnCount(2);
else setColumnCount(1);
};
update();
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, []);

// Distribute configs into fixed columns so items never shift between columns
const columns = useMemo(() => {
const cols: ConfigPublic[][] = Array.from(
{ length: columnCount },
() => [],
);
configs.forEach((config, i) => cols[i % columnCount].push(config));
return cols;
}, [configs, columnCount]);

useEffect(() => {
const timer = setTimeout(
() => setDebouncedQuery(searchInput.trim()),
SEARCH_DEBOUNCE_MS,
);
return () => clearTimeout(timer);
}, [searchInput]);

useEffect(() => {
const fetchEvaluationCounts = async () => {
if (!activeKey) return;
try {
const data = await apiFetch<EvalJob[] | { data: EvalJob[] }>(
"/api/evaluations",
activeKey.key,
);
const jobs: EvalJob[] = Array.isArray(data) ? data : data.data || [];
const counts: Record<string, number> = {};
jobs.forEach((job) => {
if (job.config_id) {
counts[job.config_id] = (counts[job.config_id] || 0) + 1;
}
});
setEvaluationCounts(counts);
} catch (e) {
console.error("Failed to fetch evaluation counts:", e);
}
};
fetchEvaluationCounts();
}, [activeKey]);

const loadVersionsForConfig = useCallback(
async (configId: string) => {
if (configState.versionItemsCache[configId]) return;
const existing = pendingVersionLoads.get(configId);
if (existing) {
await existing;
return;
}
if (!apiKey) return;

const loadPromise = (async () => {
const res = await apiFetch<{
success: boolean;
data: ConfigVersionItems[];
}>(`/api/configs/${configId}/versions`, apiKey);
if (res.success && res.data) {
configState.versionItemsCache[configId] = res.data;
}
})().finally(() => pendingVersionLoads.delete(configId));

pendingVersionLoads.set(configId, loadPromise);
await loadPromise;
},
[apiKey],
);

const loadSingleVersion = useCallback(
async (configId: string, version: number): Promise<SavedConfig | null> => {
const key = `${configId}:${version}`;
const existing = pendingSingleVersionLoads.get(key);
if (existing) return existing;
if (!apiKey) return null;

const configPublic =
configs.find((c) => c.id === configId) ??
configState.allConfigMeta?.find((m) => m.id === configId);
if (!configPublic) return null;

const loadPromise: Promise<SavedConfig | null> = (async () => {
try {
const res = await apiFetch<ConfigVersionResponse>(
`/api/configs/${configId}/versions/${version}`,
apiKey,
);
if (!res.success || !res.data) return null;
return flattenConfigVersion(configPublic, res.data);
} catch (e) {
console.error(
`Failed to fetch version ${version} for config ${configId}:`,
e,
);
return null;
}
})().finally(() => pendingSingleVersionLoads.delete(key));

pendingSingleVersionLoads.set(key, loadPromise);
return loadPromise;
},
[apiKey, configs],
);

const handleCreateNew = () => {
router.push("/configurations/prompt-editor?new=true");
};

return (
<div
className="w-full h-screen flex flex-col"
style={{ backgroundColor: colors.bg.secondary }}
>
<div className="flex flex-1 overflow-hidden">
<Sidebar collapsed={sidebarCollapsed} activeRoute="/configurations" />

<div className="flex-1 flex flex-col overflow-hidden">
<PageHeader
title="Configuration Library"
subtitle="Manage your prompts and model configurations"
/>

{/* Toolbar */}
<div
className="px-6 py-4 flex items-center gap-4"
style={{
borderBottom: `1px solid ${colors.border}`,
backgroundColor: colors.bg.primary,
}}
>
<div className="flex-1 relative">
<SearchIcon
className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4"
style={{ color: colors.text.secondary }}
/>
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search configs..."
className="w-full pl-10 pr-4 py-2 rounded-md text-sm focus:outline-none transition-colors"
style={{
backgroundColor: colors.bg.secondary,
border: `1px solid ${colors.border}`,
color: colors.text.primary,
}}
/>
</div>

<button
onClick={refetch}
disabled={isLoading}
className="p-2 rounded-md transition-colors flex items-center gap-1"
style={{
backgroundColor: colors.bg.primary,
border: `1px solid ${colors.border}`,
color: colors.text.secondary,
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = colors.bg.secondary;
e.currentTarget.style.color = colors.text.primary;
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = colors.bg.primary;
e.currentTarget.style.color = colors.text.secondary;
}}
title="Force refresh from server"
>
<RefreshIcon
className={`w-4 h-4 ${isLoading ? "animate-spin" : ""}`}
/>
</button>

<button
onClick={handleCreateNew}
className="px-4 py-2 rounded-md text-sm font-medium flex items-center gap-2 transition-colors"
style={{
backgroundColor: colors.accent.primary,
color: colors.bg.primary,
border: "none",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = colors.accent.hover)
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = colors.accent.primary)
}
>
<PlusIcon className="w-4 h-4" />
New Config
</button>
</div>

<div ref={scrollRef} className="flex-1 overflow-auto p-6">
{isLoading ? (
<LoaderBox message="Loading configurations..." size="md" />
) : error ? (
<div className="rounded-lg p-6 text-center bg-[#fef2f2] border border-[#fecaca]">
<WarningTriangleIcon className="w-12 h-12 mx-auto mb-3 text-[#dc2626]" />
<p className="text-sm font-medium text-[#dc2626]">{error}</p>
<button
onClick={() => router.push("/keystore")}
className="mt-4 px-4 py-2 rounded-md text-sm font-medium transition-colors"
style={{
backgroundColor: colors.accent.primary,
color: colors.bg.primary,
}}
>
Go to Keystore
</button>
</div>
) : configs.length === 0 ? (
<div
className="rounded-lg p-8 text-center"
style={{
backgroundColor: colors.bg.primary,
border: `2px dashed ${colors.border}`,
}}
>
{debouncedQuery ? (
<>
<SearchIcon
className="w-12 h-12 mx-auto mb-3"
style={{ color: colors.text.secondary }}
/>
<p
className="text-sm font-medium"
style={{ color: colors.text.primary }}
>
No configs match &quot;{debouncedQuery}&quot;
</p>
<button
onClick={() => setSearchInput("")}
className="mt-2 text-sm underline"
style={{ color: colors.text.secondary }}
>
Clear search
</button>
</>
) : (
<>
<GearIcon
className="w-12 h-12 mx-auto mb-3"
style={{ color: colors.text.secondary }}
/>
<p
className="text-sm font-medium"
style={{ color: colors.text.primary }}
>
No configurations yet
</p>
<p
className="text-sm mt-1"
style={{ color: colors.text.secondary }}
>
Create your first configuration to get started
</p>
<button
onClick={handleCreateNew}
className="mt-4 px-4 py-2 rounded-md text-sm font-medium transition-colors"
style={{
backgroundColor: colors.accent.primary,
color: colors.bg.primary,
}}
>
Create Config
</button>
</>
)}
</div>
) : (
<>
<div
className="grid gap-4 items-start"
style={{ gridTemplateColumns: `repeat(${columnCount}, 1fr)` }}
>
{columns.map((col, colIdx) => (
<div key={colIdx} className="flex flex-col gap-4">
{col.map((config) => (
<ConfigCard
key={config.id}
config={config}
evaluationCount={evaluationCounts[config.id] || 0}
onLoadVersions={loadVersionsForConfig}
onLoadSingleVersion={loadSingleVersion}
/>
))}
</div>
))}
</div>

{isLoadingMore && (
<div className="flex justify-center mt-6">
<Loader message="Loading more..." size="sm" />
</div>
)}
</>
)}
</div>
</div>
</div>
</div>
);
}
Loading
Loading