mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-27 02:12:19 +03:00
feat(ui): add shared components + providers page category filter
- Add CollapsibleSection, InfoTooltip, PresetSlider shared components - Add category filter chips bar to providers page - Add free-only toggle to providers page - Extend filterConfiguredProviderEntries with showFreeOnly param - Add i18n keys for Caveman/RTK tooltips and labels
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, CardSkeleton, Badge, Button, Input, Toggle } from "@/shared/components";
|
||||
import { Card, CardSkeleton, Badge, Button, Input, Toggle, CollapsibleSection } from "@/shared/components";
|
||||
import {
|
||||
FREE_PROVIDERS,
|
||||
OAUTH_PROVIDERS,
|
||||
@@ -123,7 +123,10 @@ export default function ProvidersPage() {
|
||||
} | null>(null);
|
||||
const [repairingEnv, setRepairingEnv] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [showFreeOnly, setShowFreeOnly] = useState(false);
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
||||
const notify = useNotificationStore();
|
||||
const showSection = (category: string) => !activeCategory || activeCategory === category;
|
||||
const t = useTranslations("providers");
|
||||
const tc = useTranslations("common");
|
||||
const ccCompatibleLabel = t("ccCompatibleLabel");
|
||||
@@ -419,7 +422,8 @@ export default function ProvidersPage() {
|
||||
const oauthProviderEntries = filterConfiguredProviderEntries(
|
||||
oauthProviderEntriesAll,
|
||||
showConfiguredOnly,
|
||||
searchQuery
|
||||
searchQuery,
|
||||
showFreeOnly
|
||||
);
|
||||
|
||||
const apiKeyProviderEntriesAll = buildStaticProviderEntries("apikey", getProviderStats);
|
||||
@@ -433,74 +437,86 @@ export default function ProvidersPage() {
|
||||
!EMBEDDING_RERANK_PROVIDER_IDS.has(entry.providerId)
|
||||
),
|
||||
showConfiguredOnly,
|
||||
searchQuery
|
||||
searchQuery,
|
||||
showFreeOnly
|
||||
);
|
||||
const aggregatorProviderEntries = filterConfiguredProviderEntries(
|
||||
apiKeyProviderEntriesAll.filter((entry) => AGGREGATOR_PROVIDER_IDS.has(entry.providerId)),
|
||||
showConfiguredOnly,
|
||||
searchQuery
|
||||
searchQuery,
|
||||
showFreeOnly
|
||||
);
|
||||
const imageProviderEntries = filterConfiguredProviderEntries(
|
||||
apiKeyProviderEntriesAll.filter((entry) => IMAGE_ONLY_PROVIDER_IDS.has(entry.providerId)),
|
||||
showConfiguredOnly,
|
||||
searchQuery
|
||||
searchQuery,
|
||||
showFreeOnly
|
||||
);
|
||||
const enterpriseProviderEntries = filterConfiguredProviderEntries(
|
||||
apiKeyProviderEntriesAll.filter((entry) => ENTERPRISE_CLOUD_PROVIDER_IDS.has(entry.providerId)),
|
||||
showConfiguredOnly,
|
||||
searchQuery
|
||||
searchQuery,
|
||||
showFreeOnly
|
||||
);
|
||||
const videoProviderEntries = filterConfiguredProviderEntries(
|
||||
apiKeyProviderEntriesAll.filter((entry) => VIDEO_PROVIDER_IDS.has(entry.providerId)),
|
||||
showConfiguredOnly,
|
||||
searchQuery
|
||||
searchQuery,
|
||||
showFreeOnly
|
||||
);
|
||||
const embeddingRerankProviderEntries = filterConfiguredProviderEntries(
|
||||
apiKeyProviderEntriesAll.filter((entry) => EMBEDDING_RERANK_PROVIDER_IDS.has(entry.providerId)),
|
||||
showConfiguredOnly,
|
||||
searchQuery
|
||||
searchQuery,
|
||||
showFreeOnly
|
||||
);
|
||||
|
||||
const webCookieProviderEntriesAll = buildStaticProviderEntries("web-cookie", getProviderStats);
|
||||
const webCookieProviderEntries = filterConfiguredProviderEntries(
|
||||
webCookieProviderEntriesAll,
|
||||
showConfiguredOnly,
|
||||
searchQuery
|
||||
searchQuery,
|
||||
showFreeOnly
|
||||
);
|
||||
|
||||
const localProviderEntriesAll = buildStaticProviderEntries("local", getProviderStats);
|
||||
const localProviderEntries = filterConfiguredProviderEntries(
|
||||
localProviderEntriesAll,
|
||||
showConfiguredOnly,
|
||||
searchQuery
|
||||
searchQuery,
|
||||
showFreeOnly
|
||||
);
|
||||
|
||||
const searchProviderEntriesAll = buildStaticProviderEntries("search", getProviderStats);
|
||||
const searchProviderEntries = filterConfiguredProviderEntries(
|
||||
searchProviderEntriesAll,
|
||||
showConfiguredOnly,
|
||||
searchQuery
|
||||
searchQuery,
|
||||
showFreeOnly
|
||||
);
|
||||
|
||||
const audioProviderEntriesAll = buildStaticProviderEntries("audio", getProviderStats);
|
||||
const audioProviderEntries = filterConfiguredProviderEntries(
|
||||
audioProviderEntriesAll,
|
||||
showConfiguredOnly,
|
||||
searchQuery
|
||||
searchQuery,
|
||||
showFreeOnly
|
||||
);
|
||||
|
||||
const cloudAgentProviderEntriesAll = buildStaticProviderEntries("cloud-agent", getProviderStats);
|
||||
const cloudAgentProviderEntries = filterConfiguredProviderEntries(
|
||||
cloudAgentProviderEntriesAll,
|
||||
showConfiguredOnly,
|
||||
searchQuery
|
||||
searchQuery,
|
||||
showFreeOnly
|
||||
);
|
||||
|
||||
const upstreamProxyEntriesAll = buildStaticProviderEntries("upstream-proxy", getProviderStats);
|
||||
const upstreamProxyEntries = filterConfiguredProviderEntries(
|
||||
upstreamProxyEntriesAll,
|
||||
showConfiguredOnly,
|
||||
searchQuery
|
||||
searchQuery,
|
||||
showFreeOnly
|
||||
);
|
||||
|
||||
const compatibleProviderEntriesAll = [
|
||||
@@ -529,7 +545,8 @@ export default function ProvidersPage() {
|
||||
const compatibleProviderEntries = filterConfiguredProviderEntries(
|
||||
compatibleProviderEntriesAll,
|
||||
showConfiguredOnly,
|
||||
searchQuery
|
||||
searchQuery,
|
||||
showFreeOnly
|
||||
);
|
||||
|
||||
const FREE_SECTION_IDS = new Set([
|
||||
@@ -754,6 +771,48 @@ export default function ProvidersPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category Filter Chips */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{[
|
||||
{ key: null, label: t("allProviders") || "All" },
|
||||
{ key: "free", label: t("freeProviders") || "Free" },
|
||||
{ key: "oauth", label: t("oauthProviders") },
|
||||
{ key: "apikey", label: t("apiKeyProviders") },
|
||||
{ key: "webcookie", label: t("webCookieProviders") || "Web Cookie" },
|
||||
{ key: "search", label: t("searchProviders") || "Search" },
|
||||
{ key: "audio", label: t("audioProviders") || "Audio" },
|
||||
{ key: "cloudagent", label: t("cloudAgentProviders") || "Cloud Agent" },
|
||||
{ key: "local", label: t("localProviders") || "Local" },
|
||||
{ key: "compatible", label: t("compatibleProviders") || "Compatible" },
|
||||
].map((cat) => (
|
||||
<button
|
||||
key={cat.key ?? "all"}
|
||||
onClick={() => {
|
||||
if (cat.key === "free") {
|
||||
setShowFreeOnly(true);
|
||||
setActiveCategory(null);
|
||||
} else {
|
||||
setShowFreeOnly(false);
|
||||
setActiveCategory(cat.key);
|
||||
}
|
||||
}}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-full border transition-colors ${
|
||||
(cat.key === "free" && showFreeOnly) ||
|
||||
(cat.key !== "free" && !showFreeOnly && activeCategory === cat.key)
|
||||
? "bg-primary text-white border-primary"
|
||||
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/30"
|
||||
}`}
|
||||
>
|
||||
{cat.key === "free" && (
|
||||
<span className="material-symbols-outlined text-[12px] mr-0.5 align-middle">
|
||||
star
|
||||
</span>
|
||||
)}
|
||||
{cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Expiration Banner */}
|
||||
{expirations?.summary &&
|
||||
(expirations.summary.expired > 0 || expirations.summary.expiringSoon > 0) && (
|
||||
@@ -844,6 +903,20 @@ export default function ProvidersPage() {
|
||||
<ProviderCountBadge {...countConfigured(oauthProviderEntriesAll)} />
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Toggle
|
||||
size="sm"
|
||||
checked={showFreeOnly}
|
||||
onChange={setShowFreeOnly}
|
||||
label={t("showFreeOnly") || "Free only"}
|
||||
className="rounded-lg border border-border bg-bg-subtle px-3 py-1.5"
|
||||
/>
|
||||
<Toggle
|
||||
size="sm"
|
||||
checked={showConfiguredOnly}
|
||||
onChange={setShowConfiguredOnly}
|
||||
label={t("showConfiguredOnly")}
|
||||
className="rounded-lg border border-border bg-bg-subtle px-3 py-1.5"
|
||||
/>
|
||||
<button
|
||||
onClick={handleZedImport}
|
||||
disabled={importingZed}
|
||||
|
||||
@@ -70,7 +70,8 @@ export function buildStaticProviderEntries(
|
||||
export function filterConfiguredProviderEntries<TProvider>(
|
||||
entries: ProviderEntry<TProvider>[],
|
||||
showConfiguredOnly: boolean,
|
||||
searchQuery?: string
|
||||
searchQuery?: string,
|
||||
showFreeOnly?: boolean
|
||||
): ProviderEntry<TProvider>[] {
|
||||
let filtered = entries;
|
||||
|
||||
@@ -78,6 +79,13 @@ export function filterConfiguredProviderEntries<TProvider>(
|
||||
filtered = filtered.filter((entry) => Number(entry.stats?.total || 0) > 0);
|
||||
}
|
||||
|
||||
if (showFreeOnly) {
|
||||
filtered = filtered.filter((entry) => {
|
||||
const provider = entry.provider as Record<string, unknown>;
|
||||
return provider.hasFree === true;
|
||||
});
|
||||
}
|
||||
|
||||
if (searchQuery && searchQuery.trim()) {
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
filtered = filtered.filter((entry) => {
|
||||
|
||||
@@ -3949,10 +3949,17 @@
|
||||
"toolResults": "Tool results",
|
||||
"assistantMessages": "Assistant messages",
|
||||
"codeBlocks": "Code blocks",
|
||||
"filterCatalog": "Filter Catalog",
|
||||
"filterCatalogDesc": "Available output filters by category",
|
||||
"guidedConfig": "Configuration",
|
||||
"guidedConfigDesc": "Adjust how RTK filters your terminal output",
|
||||
"maxLines": "Max lines",
|
||||
"maxChars": "Max chars",
|
||||
"deduplicateThreshold": "Deduplicate threshold",
|
||||
"customFilters": "Custom filters",
|
||||
"detectedType": "Detected type",
|
||||
"confidence": "Confidence",
|
||||
"beforeAfter": "Before / After",
|
||||
"trustProjectFilters": "Trust project filters",
|
||||
"rawOutputRetention": "Raw output retention",
|
||||
"rawOutputNever": "Never",
|
||||
@@ -3961,6 +3968,11 @@
|
||||
"rawOutputMaxBytes": "Raw output max bytes",
|
||||
"filterTesting": "Filter Testing",
|
||||
"pasteOutput": "Paste tool output to test filtering...",
|
||||
"presetHigh": "Aggressive",
|
||||
"presetLow": "Light",
|
||||
"presetMaxChars": "Max output characters",
|
||||
"presetMaxLines": "Max output lines",
|
||||
"presetMedium": "Standard",
|
||||
"run": "Run",
|
||||
"result": "Result",
|
||||
"previewEmpty": "Run a sample to preview RTK output.",
|
||||
@@ -3968,7 +3980,11 @@
|
||||
"tokensFiltered": "Tokens filtered",
|
||||
"filtersActive": "Filters active",
|
||||
"requests": "Requests",
|
||||
"avgSavings": "Avg savings"
|
||||
"avgSavings": "Avg savings",
|
||||
"searchFilters": "Search filters...",
|
||||
"tooltipDedup": "How aggressively to remove repeated lines.",
|
||||
"tooltipMaxChars": "Maximum characters per output block.",
|
||||
"tooltipMaxLines": "Maximum lines to keep from command output. Excess is truncated."
|
||||
},
|
||||
"contextCombos": {
|
||||
"title": "Compression Combos",
|
||||
@@ -3995,22 +4011,41 @@
|
||||
"contextCaveman": {
|
||||
"title": "Caveman Engine",
|
||||
"description": "Rule-based message compression, language packs, analytics and output mode controls.",
|
||||
"advancedConfig": "Advanced Configuration",
|
||||
"advancedConfigDesc": "Fine-tune compression behavior",
|
||||
"aggressiveSettings": "Aggressive Settings",
|
||||
"aggressiveSettingsDesc": "Maximum compression with potential quality trade-offs",
|
||||
"requests": "Requests",
|
||||
"tokensSaved": "Tokens saved",
|
||||
"savingsPercent": "Savings %",
|
||||
"avgLatency": "Avg latency",
|
||||
"languagePacks": "Language Packs",
|
||||
"languagePacksDesc": "Enable compression rules for specific languages.",
|
||||
"labelAutoTrigger": "Auto-compress when context exceeds",
|
||||
"labelCompressionRate": "Compression strength",
|
||||
"labelMaxTokens": "Target tokens per message",
|
||||
"labelMinLength": "Minimum message length",
|
||||
"labelMinSavings": "Minimum tokens to save",
|
||||
"enabled": "Enabled",
|
||||
"autoDetect": "Auto-detect language",
|
||||
"rulesCount": "{count} rules",
|
||||
"analyticsTitle": "Compression Analytics",
|
||||
"noAnalytics": "No compression analytics yet.",
|
||||
"outputMode": "Output Mode",
|
||||
"outputModeDesc": "Control how compressed output appears",
|
||||
"outputModeTitle": "Output Mode",
|
||||
"outputModeDesc": "Instructs the LLM to respond in a terse, compact format.",
|
||||
"quickSettings": "Quick Settings",
|
||||
"quickSettingsDesc": "Basic compression settings to get started",
|
||||
"autoClarity": "Auto-Clarity Bypass",
|
||||
"bypassConditions": "Bypass conditions",
|
||||
"bypassConditionsList": "security, irreversible, clarification, order-sensitive",
|
||||
"tooltipAutoTrigger": "Automatically compress when context exceeds this size.",
|
||||
"tooltipCompressionRate": "0.0 = none, 1.0 = maximum. 0.5 = balanced.",
|
||||
"tooltipMaxTokens": "Target token count after compression. Lower = more aggressive.",
|
||||
"tooltipMinLength": "Messages shorter than this are not compressed. Lower = more compression.",
|
||||
"tooltipMinSavings": "Only compress if it saves at least this many tokens.",
|
||||
"ultraSettings": "Ultra Settings",
|
||||
"ultraSettingsDesc": "SLM-powered semantic compression",
|
||||
"preview": {
|
||||
"lite": "Respond concise. Preserve technical terms, code, errors, URLs, and identifiers.",
|
||||
"full": "Respond terse and compact. Preserve all technical substance.",
|
||||
|
||||
66
src/shared/components/CollapsibleSection.tsx
Normal file
66
src/shared/components/CollapsibleSection.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
interface CollapsibleSectionProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
count?: number;
|
||||
defaultOpen?: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function CollapsibleSection({
|
||||
title,
|
||||
description,
|
||||
count,
|
||||
defaultOpen = true,
|
||||
children,
|
||||
className,
|
||||
}: CollapsibleSectionProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div className={cn("border border-black/5 dark:border-white/5 rounded-lg", className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={cn(
|
||||
"flex items-center justify-between w-full px-4 py-3 text-left",
|
||||
"hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors",
|
||||
"rounded-t-lg",
|
||||
!open && "rounded-b-lg"
|
||||
)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="text-sm font-semibold text-text-main truncate">{title}</span>
|
||||
{count != null && (
|
||||
<span className="px-2 py-0.5 text-[10px] font-semibold rounded-full bg-black/5 dark:bg-white/10 text-text-muted">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
{description && (
|
||||
<span className="text-xs text-text-muted truncate hidden sm:inline">{description}</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className="material-symbols-outlined text-[20px] text-text-muted transition-transform duration-200 shrink-0"
|
||||
style={{ transform: open ? "rotate(0deg)" : "rotate(-90deg)" }}
|
||||
>
|
||||
expand_more
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden transition-[max-height] duration-200 ease-in-out",
|
||||
open ? "max-h-[2000px]" : "max-h-0"
|
||||
)}
|
||||
>
|
||||
<div className="px-4 pb-4 pt-1">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/shared/components/InfoTooltip.tsx
Normal file
34
src/shared/components/InfoTooltip.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
interface InfoTooltipProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function InfoTooltip({ text, className }: InfoTooltipProps) {
|
||||
return (
|
||||
<span className={cn("relative inline-flex group", className)}>
|
||||
<span
|
||||
className="material-symbols-outlined text-[16px] text-text-muted cursor-help"
|
||||
aria-label={text}
|
||||
>
|
||||
info
|
||||
</span>
|
||||
<span
|
||||
role="tooltip"
|
||||
className={cn(
|
||||
"absolute bottom-full left-1/2 -translate-x-1/2 mb-2",
|
||||
"px-2.5 py-1.5 text-xs font-medium text-white bg-gray-900/95 rounded-md shadow-lg",
|
||||
"whitespace-nowrap pointer-events-none",
|
||||
"border border-white/10",
|
||||
"opacity-0 group-hover:opacity-100 transition-opacity duration-150",
|
||||
"z-50"
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
64
src/shared/components/PresetSlider.tsx
Normal file
64
src/shared/components/PresetSlider.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
interface PresetSliderProps {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
presets: { label: string; value: number }[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function PresetSlider({
|
||||
value,
|
||||
onChange,
|
||||
presets,
|
||||
min = 0,
|
||||
max = 100,
|
||||
step = 1,
|
||||
className,
|
||||
}: PresetSliderProps) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-3", className)}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{presets.map((preset) => (
|
||||
<button
|
||||
key={preset.value}
|
||||
type="button"
|
||||
onClick={() => onChange(preset.value)}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-xs font-medium rounded-full transition-colors",
|
||||
"border",
|
||||
value === preset.value
|
||||
? "bg-primary text-white border-primary"
|
||||
: "bg-transparent text-text-muted border-black/10 dark:border-white/10 hover:border-primary/30 hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className={cn(
|
||||
"w-full h-1.5 rounded-full appearance-none cursor-pointer",
|
||||
"bg-black/10 dark:bg-white/10",
|
||||
"accent-primary"
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-text-muted">
|
||||
<span>{min}</span>
|
||||
<span className="font-medium text-text-main">{value}</span>
|
||||
<span>{max}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,9 @@ export { default as NotificationToast } from "./NotificationToast";
|
||||
export { default as FilterBar } from "./FilterBar";
|
||||
export { default as ColumnToggle } from "./ColumnToggle";
|
||||
export { default as DataTable } from "./DataTable";
|
||||
export { default as CollapsibleSection } from "./CollapsibleSection";
|
||||
export { default as InfoTooltip } from "./InfoTooltip";
|
||||
export { default as PresetSlider } from "./PresetSlider";
|
||||
|
||||
// Layouts
|
||||
export * from "./layouts";
|
||||
|
||||
Reference in New Issue
Block a user