Repository navigation
feat(dashboard): filter Free Provider Rankings by configured/available (#6150) #6251
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0ee77c0
feaad38
85c49d5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -61,15 +61,20 @@ export default function FreeProviderRankingsPage() { | |
| const [error, setError] = useState(""); | ||
| const [filter, setFilter] = useState<string>(""); | ||
| const [configuredOnly, setConfiguredOnly] = useState(false); | ||
| const [configuredProviderIds, setConfiguredProviderIds] = useState<Set<string>>(new Set()); | ||
| const [availableOnly, setAvailableOnly] = useState(false); | ||
|
|
||
| const fetchRankings = useCallback( | ||
| async (category?: string) => { | ||
| async (category?: string, opts?: { configuredOnly?: boolean; availableOnly?: boolean }) => { | ||
| setLoading(true); | ||
| setError(""); | ||
| try { | ||
| const url = category | ||
| ? `/api/free-provider-rankings?category=${category}` | ||
| const params = new URLSearchParams(); | ||
| if (category) params.set("category", category); | ||
| if (opts?.configuredOnly) params.set("configuredOnly", "1"); | ||
| if (opts?.availableOnly) params.set("availableOnly", "1"); | ||
| const qs = params.toString(); | ||
| const url = qs | ||
| ? `/api/free-provider-rankings?${qs}` | ||
| : "/api/free-provider-rankings"; | ||
| const res = await fetch(url); | ||
| if (!res.ok) throw new Error(`HTTP ${res.status}`); | ||
|
|
@@ -85,30 +90,8 @@ export default function FreeProviderRankingsPage() { | |
| ); | ||
|
|
||
| useEffect(() => { | ||
| fetchRankings(filter || undefined); | ||
| }, [filter, fetchRankings]); | ||
|
|
||
| useEffect(() => { | ||
| let active = true; | ||
| fetch("/api/providers") | ||
| .then((res) => (res.ok ? res.json() : { connections: [] })) | ||
| .then((data) => { | ||
| if (!active) return; | ||
| const ids = new Set<string>(); | ||
| for (const conn of data.connections || []) { | ||
| if (conn?.provider) ids.add(conn.provider); | ||
| } | ||
| setConfiguredProviderIds(ids); | ||
| }) | ||
| .catch(() => {}); | ||
| return () => { | ||
| active = false; | ||
| }; | ||
| }, []); | ||
|
|
||
| const displayedRankings = configuredOnly | ||
| ? rankings.filter((r) => configuredProviderIds.has(r.id)) | ||
| : rankings; | ||
| fetchRankings(filter || undefined, { configuredOnly, availableOnly }); | ||
| }, [filter, configuredOnly, availableOnly, fetchRankings]); | ||
|
|
||
| return ( | ||
| <div className="flex flex-col gap-6"> | ||
|
|
@@ -135,30 +118,33 @@ export default function FreeProviderRankingsPage() { | |
| {t(opt.labelKey)} | ||
| </button> | ||
| ))} | ||
| <div className="ml-auto flex items-center gap-2"> | ||
| <label | ||
| htmlFor="configured-only-toggle" | ||
| className="text-sm text-text-muted select-none cursor-pointer" | ||
| title={t("configuredOnlyHint")} | ||
| > | ||
| {t("configuredOnly")} | ||
| </label> | ||
| <button | ||
| id="configured-only-toggle" | ||
| role="switch" | ||
| aria-checked={configuredOnly} | ||
| onClick={() => setConfiguredOnly(!configuredOnly)} | ||
| className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-2 ${ | ||
| configuredOnly ? "bg-violet-500" : "bg-border" | ||
| }`} | ||
| > | ||
| <span | ||
| className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg transition-transform ${ | ||
| configuredOnly ? "translate-x-4" : "translate-x-0" | ||
| }`} | ||
| /> | ||
| </button> | ||
| </div> | ||
| </div> | ||
|
|
||
| {/* Availability toggles (default off → show all providers) */} | ||
| <div className="flex items-center gap-2 flex-wrap"> | ||
| <button | ||
| onClick={() => setConfiguredOnly((v) => !v)} | ||
| aria-pressed={configuredOnly} | ||
| className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${ | ||
| configuredOnly | ||
| ? "bg-emerald-500 border-emerald-500 text-white" | ||
| : "border-border text-text-muted hover:text-text-main hover:border-emerald-500/50" | ||
| }`} | ||
| > | ||
| {t("filterConfiguredOnly")} | ||
| </button> | ||
| <button | ||
| onClick={() => setAvailableOnly((v) => !v)} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| aria-pressed={availableOnly} | ||
| title={t("filterAvailableOnlyHelp")} | ||
| className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${ | ||
| availableOnly | ||
| ? "bg-emerald-500 border-emerald-500 text-white" | ||
| : "border-border text-text-muted hover:text-text-main hover:border-emerald-500/50" | ||
| }`} | ||
| > | ||
| {t("filterAvailableOnly")} | ||
| </button> | ||
| </div> | ||
|
|
||
| {error && <div className="p-3 rounded-lg bg-red-500/10 text-red-400 text-sm">{error}</div>} | ||
|
|
@@ -170,9 +156,9 @@ export default function FreeProviderRankingsPage() { | |
| ) : ( | ||
| <> | ||
| {/* Top 3 Podium */} | ||
| {displayedRankings.length >= 3 && ( | ||
| {rankings.length >= 3 && ( | ||
| <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> | ||
| {displayedRankings.slice(0, 3).map((provider, idx) => ( | ||
| {rankings.slice(0, 3).map((provider, idx) => ( | ||
| <Card key={provider.id} className="relative overflow-hidden"> | ||
| <div | ||
| className={`absolute top-0 left-0 right-0 h-1 ${ | ||
|
|
@@ -216,7 +202,7 @@ export default function FreeProviderRankingsPage() { | |
| )} | ||
|
|
||
| {/* Full List */} | ||
| {displayedRankings.length > 0 && ( | ||
| {rankings.length > 0 && ( | ||
| <Card> | ||
| <div className="overflow-x-auto"> | ||
| <table className="w-full"> | ||
|
|
@@ -229,11 +215,10 @@ export default function FreeProviderRankingsPage() { | |
| <th className="pb-3 font-medium text-right">{t("colAvgScore")}</th> | ||
| <th className="pb-3 font-medium text-right">{t("colModels")}</th> | ||
| <th className="pb-3 font-medium text-right">{t("colType")}</th> | ||
| <th className="pb-3 font-medium text-right">{t("colConfigured")}</th> | ||
| </tr> | ||
| </thead> | ||
| <tbody> | ||
| {displayedRankings.map((provider, idx) => ( | ||
| {rankings.map((provider, idx) => ( | ||
| <tr key={provider.id} className="border-b border-border/50 last:border-b-0"> | ||
| <td className="py-3 text-text-muted font-mono">{idx + 1}</td> | ||
| <td className="py-3"> | ||
|
|
@@ -278,17 +263,6 @@ export default function FreeProviderRankingsPage() { | |
| {provider.category.toUpperCase()} | ||
| </span> | ||
| </td> | ||
| <td className="py-3 text-right"> | ||
| {configuredProviderIds.has(provider.id) ? ( | ||
| <span className="text-xs px-2 py-1 rounded bg-green-500/10 text-green-500"> | ||
| ✓ | ||
| </span> | ||
| ) : ( | ||
| <span className="text-xs px-2 py-1 rounded bg-text-muted/10 text-text-muted"> | ||
| — | ||
| </span> | ||
| )} | ||
| </td> | ||
| </tr> | ||
| ))} | ||
| </tbody> | ||
|
|
@@ -297,13 +271,9 @@ export default function FreeProviderRankingsPage() { | |
| </Card> | ||
| )} | ||
|
|
||
| {displayedRankings.length === 0 && !error && ( | ||
| {rankings.length === 0 && !error && ( | ||
| <Card> | ||
| <div className="text-center py-12 text-text-muted"> | ||
| {configuredOnly && rankings.length > 0 | ||
| ? t("noConfiguredProviders") | ||
| : t("emptyState")} | ||
| </div> | ||
| <div className="text-center py-12 text-text-muted">{t("emptyState")}</div> | ||
| </Card> | ||
| )} | ||
| </> | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -3,6 +3,12 @@ import { z } from "zod"; | |||||||||||||||||
| import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; | ||||||||||||||||||
| import { computeFreeProviderRankings } from "@/lib/freeProviderRankings"; | ||||||||||||||||||
|
|
||||||||||||||||||
| // Coerce common truthy query-string forms ("1", "true", "yes") to a boolean. | ||||||||||||||||||
| const boolParam = z | ||||||||||||||||||
| .string() | ||||||||||||||||||
| .optional() | ||||||||||||||||||
| .transform((val) => val === "1" || val === "true" || val === "yes"); | ||||||||||||||||||
|
Comment on lines
+7
to
+10
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current
Suggested change
|
||||||||||||||||||
|
|
||||||||||||||||||
| const QuerySchema = z.object({ | ||||||||||||||||||
| category: z.string().min(1).max(50).optional(), | ||||||||||||||||||
| limit: z | ||||||||||||||||||
|
|
@@ -13,6 +19,9 @@ const QuerySchema = z.object({ | |||||||||||||||||
| const n = Number(val); | ||||||||||||||||||
| return Number.isFinite(n) && n >= 1 ? Math.min(Math.round(n), 100) : 50; | ||||||||||||||||||
| }), | ||||||||||||||||||
| // Additive filters (default off → current behavior). `availableOnly` implies configured. | ||||||||||||||||||
| configuredOnly: boolParam, | ||||||||||||||||||
| availableOnly: boolParam, | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| export async function OPTIONS() { | ||||||||||||||||||
|
|
@@ -24,6 +33,8 @@ export async function GET(request: NextRequest) { | |||||||||||||||||
| const parsed = QuerySchema.safeParse({ | ||||||||||||||||||
| category: url.searchParams.get("category") || undefined, | ||||||||||||||||||
| limit: url.searchParams.get("limit") || undefined, | ||||||||||||||||||
| configuredOnly: url.searchParams.get("configuredOnly") || undefined, | ||||||||||||||||||
| availableOnly: url.searchParams.get("availableOnly") || undefined, | ||||||||||||||||||
|
Comment on lines
+36
to
+37
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using the logical OR operator
Suggested change
|
||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| if (!parsed.success) { | ||||||||||||||||||
|
|
@@ -33,8 +44,11 @@ export async function GET(request: NextRequest) { | |||||||||||||||||
| ); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| const { category, limit } = parsed.data; | ||||||||||||||||||
| const rankings = computeFreeProviderRankings(category, limit); | ||||||||||||||||||
| const { category, limit, configuredOnly, availableOnly } = parsed.data; | ||||||||||||||||||
| const rankings = await computeFreeProviderRankings(category, limit, { | ||||||||||||||||||
| configuredOnly, | ||||||||||||||||||
| availableOnly, | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| return NextResponse.json({ rankings }, { headers: CORS_HEADERS }); | ||||||||||||||||||
| } | ||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
configuredOnlyis toggled off,availableOnlyshould also be toggled off to maintain UI state consistency, since a provider cannot be available if it is not configured.