mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 12:52:11 +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";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
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 {
|
import {
|
||||||
FREE_PROVIDERS,
|
FREE_PROVIDERS,
|
||||||
OAUTH_PROVIDERS,
|
OAUTH_PROVIDERS,
|
||||||
@@ -123,7 +123,10 @@ export default function ProvidersPage() {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [repairingEnv, setRepairingEnv] = useState(false);
|
const [repairingEnv, setRepairingEnv] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [showFreeOnly, setShowFreeOnly] = useState(false);
|
||||||
|
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
||||||
const notify = useNotificationStore();
|
const notify = useNotificationStore();
|
||||||
|
const showSection = (category: string) => !activeCategory || activeCategory === category;
|
||||||
const t = useTranslations("providers");
|
const t = useTranslations("providers");
|
||||||
const tc = useTranslations("common");
|
const tc = useTranslations("common");
|
||||||
const ccCompatibleLabel = t("ccCompatibleLabel");
|
const ccCompatibleLabel = t("ccCompatibleLabel");
|
||||||
@@ -419,7 +422,8 @@ export default function ProvidersPage() {
|
|||||||
const oauthProviderEntries = filterConfiguredProviderEntries(
|
const oauthProviderEntries = filterConfiguredProviderEntries(
|
||||||
oauthProviderEntriesAll,
|
oauthProviderEntriesAll,
|
||||||
showConfiguredOnly,
|
showConfiguredOnly,
|
||||||
searchQuery
|
searchQuery,
|
||||||
|
showFreeOnly
|
||||||
);
|
);
|
||||||
|
|
||||||
const apiKeyProviderEntriesAll = buildStaticProviderEntries("apikey", getProviderStats);
|
const apiKeyProviderEntriesAll = buildStaticProviderEntries("apikey", getProviderStats);
|
||||||
@@ -433,74 +437,86 @@ export default function ProvidersPage() {
|
|||||||
!EMBEDDING_RERANK_PROVIDER_IDS.has(entry.providerId)
|
!EMBEDDING_RERANK_PROVIDER_IDS.has(entry.providerId)
|
||||||
),
|
),
|
||||||
showConfiguredOnly,
|
showConfiguredOnly,
|
||||||
searchQuery
|
searchQuery,
|
||||||
|
showFreeOnly
|
||||||
);
|
);
|
||||||
const aggregatorProviderEntries = filterConfiguredProviderEntries(
|
const aggregatorProviderEntries = filterConfiguredProviderEntries(
|
||||||
apiKeyProviderEntriesAll.filter((entry) => AGGREGATOR_PROVIDER_IDS.has(entry.providerId)),
|
apiKeyProviderEntriesAll.filter((entry) => AGGREGATOR_PROVIDER_IDS.has(entry.providerId)),
|
||||||
showConfiguredOnly,
|
showConfiguredOnly,
|
||||||
searchQuery
|
searchQuery,
|
||||||
|
showFreeOnly
|
||||||
);
|
);
|
||||||
const imageProviderEntries = filterConfiguredProviderEntries(
|
const imageProviderEntries = filterConfiguredProviderEntries(
|
||||||
apiKeyProviderEntriesAll.filter((entry) => IMAGE_ONLY_PROVIDER_IDS.has(entry.providerId)),
|
apiKeyProviderEntriesAll.filter((entry) => IMAGE_ONLY_PROVIDER_IDS.has(entry.providerId)),
|
||||||
showConfiguredOnly,
|
showConfiguredOnly,
|
||||||
searchQuery
|
searchQuery,
|
||||||
|
showFreeOnly
|
||||||
);
|
);
|
||||||
const enterpriseProviderEntries = filterConfiguredProviderEntries(
|
const enterpriseProviderEntries = filterConfiguredProviderEntries(
|
||||||
apiKeyProviderEntriesAll.filter((entry) => ENTERPRISE_CLOUD_PROVIDER_IDS.has(entry.providerId)),
|
apiKeyProviderEntriesAll.filter((entry) => ENTERPRISE_CLOUD_PROVIDER_IDS.has(entry.providerId)),
|
||||||
showConfiguredOnly,
|
showConfiguredOnly,
|
||||||
searchQuery
|
searchQuery,
|
||||||
|
showFreeOnly
|
||||||
);
|
);
|
||||||
const videoProviderEntries = filterConfiguredProviderEntries(
|
const videoProviderEntries = filterConfiguredProviderEntries(
|
||||||
apiKeyProviderEntriesAll.filter((entry) => VIDEO_PROVIDER_IDS.has(entry.providerId)),
|
apiKeyProviderEntriesAll.filter((entry) => VIDEO_PROVIDER_IDS.has(entry.providerId)),
|
||||||
showConfiguredOnly,
|
showConfiguredOnly,
|
||||||
searchQuery
|
searchQuery,
|
||||||
|
showFreeOnly
|
||||||
);
|
);
|
||||||
const embeddingRerankProviderEntries = filterConfiguredProviderEntries(
|
const embeddingRerankProviderEntries = filterConfiguredProviderEntries(
|
||||||
apiKeyProviderEntriesAll.filter((entry) => EMBEDDING_RERANK_PROVIDER_IDS.has(entry.providerId)),
|
apiKeyProviderEntriesAll.filter((entry) => EMBEDDING_RERANK_PROVIDER_IDS.has(entry.providerId)),
|
||||||
showConfiguredOnly,
|
showConfiguredOnly,
|
||||||
searchQuery
|
searchQuery,
|
||||||
|
showFreeOnly
|
||||||
);
|
);
|
||||||
|
|
||||||
const webCookieProviderEntriesAll = buildStaticProviderEntries("web-cookie", getProviderStats);
|
const webCookieProviderEntriesAll = buildStaticProviderEntries("web-cookie", getProviderStats);
|
||||||
const webCookieProviderEntries = filterConfiguredProviderEntries(
|
const webCookieProviderEntries = filterConfiguredProviderEntries(
|
||||||
webCookieProviderEntriesAll,
|
webCookieProviderEntriesAll,
|
||||||
showConfiguredOnly,
|
showConfiguredOnly,
|
||||||
searchQuery
|
searchQuery,
|
||||||
|
showFreeOnly
|
||||||
);
|
);
|
||||||
|
|
||||||
const localProviderEntriesAll = buildStaticProviderEntries("local", getProviderStats);
|
const localProviderEntriesAll = buildStaticProviderEntries("local", getProviderStats);
|
||||||
const localProviderEntries = filterConfiguredProviderEntries(
|
const localProviderEntries = filterConfiguredProviderEntries(
|
||||||
localProviderEntriesAll,
|
localProviderEntriesAll,
|
||||||
showConfiguredOnly,
|
showConfiguredOnly,
|
||||||
searchQuery
|
searchQuery,
|
||||||
|
showFreeOnly
|
||||||
);
|
);
|
||||||
|
|
||||||
const searchProviderEntriesAll = buildStaticProviderEntries("search", getProviderStats);
|
const searchProviderEntriesAll = buildStaticProviderEntries("search", getProviderStats);
|
||||||
const searchProviderEntries = filterConfiguredProviderEntries(
|
const searchProviderEntries = filterConfiguredProviderEntries(
|
||||||
searchProviderEntriesAll,
|
searchProviderEntriesAll,
|
||||||
showConfiguredOnly,
|
showConfiguredOnly,
|
||||||
searchQuery
|
searchQuery,
|
||||||
|
showFreeOnly
|
||||||
);
|
);
|
||||||
|
|
||||||
const audioProviderEntriesAll = buildStaticProviderEntries("audio", getProviderStats);
|
const audioProviderEntriesAll = buildStaticProviderEntries("audio", getProviderStats);
|
||||||
const audioProviderEntries = filterConfiguredProviderEntries(
|
const audioProviderEntries = filterConfiguredProviderEntries(
|
||||||
audioProviderEntriesAll,
|
audioProviderEntriesAll,
|
||||||
showConfiguredOnly,
|
showConfiguredOnly,
|
||||||
searchQuery
|
searchQuery,
|
||||||
|
showFreeOnly
|
||||||
);
|
);
|
||||||
|
|
||||||
const cloudAgentProviderEntriesAll = buildStaticProviderEntries("cloud-agent", getProviderStats);
|
const cloudAgentProviderEntriesAll = buildStaticProviderEntries("cloud-agent", getProviderStats);
|
||||||
const cloudAgentProviderEntries = filterConfiguredProviderEntries(
|
const cloudAgentProviderEntries = filterConfiguredProviderEntries(
|
||||||
cloudAgentProviderEntriesAll,
|
cloudAgentProviderEntriesAll,
|
||||||
showConfiguredOnly,
|
showConfiguredOnly,
|
||||||
searchQuery
|
searchQuery,
|
||||||
|
showFreeOnly
|
||||||
);
|
);
|
||||||
|
|
||||||
const upstreamProxyEntriesAll = buildStaticProviderEntries("upstream-proxy", getProviderStats);
|
const upstreamProxyEntriesAll = buildStaticProviderEntries("upstream-proxy", getProviderStats);
|
||||||
const upstreamProxyEntries = filterConfiguredProviderEntries(
|
const upstreamProxyEntries = filterConfiguredProviderEntries(
|
||||||
upstreamProxyEntriesAll,
|
upstreamProxyEntriesAll,
|
||||||
showConfiguredOnly,
|
showConfiguredOnly,
|
||||||
searchQuery
|
searchQuery,
|
||||||
|
showFreeOnly
|
||||||
);
|
);
|
||||||
|
|
||||||
const compatibleProviderEntriesAll = [
|
const compatibleProviderEntriesAll = [
|
||||||
@@ -529,7 +545,8 @@ export default function ProvidersPage() {
|
|||||||
const compatibleProviderEntries = filterConfiguredProviderEntries(
|
const compatibleProviderEntries = filterConfiguredProviderEntries(
|
||||||
compatibleProviderEntriesAll,
|
compatibleProviderEntriesAll,
|
||||||
showConfiguredOnly,
|
showConfiguredOnly,
|
||||||
searchQuery
|
searchQuery,
|
||||||
|
showFreeOnly
|
||||||
);
|
);
|
||||||
|
|
||||||
const FREE_SECTION_IDS = new Set([
|
const FREE_SECTION_IDS = new Set([
|
||||||
@@ -754,6 +771,48 @@ export default function ProvidersPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Expiration Banner */}
|
||||||
{expirations?.summary &&
|
{expirations?.summary &&
|
||||||
(expirations.summary.expired > 0 || expirations.summary.expiringSoon > 0) && (
|
(expirations.summary.expired > 0 || expirations.summary.expiringSoon > 0) && (
|
||||||
@@ -844,6 +903,20 @@ export default function ProvidersPage() {
|
|||||||
<ProviderCountBadge {...countConfigured(oauthProviderEntriesAll)} />
|
<ProviderCountBadge {...countConfigured(oauthProviderEntriesAll)} />
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<button
|
||||||
onClick={handleZedImport}
|
onClick={handleZedImport}
|
||||||
disabled={importingZed}
|
disabled={importingZed}
|
||||||
|
|||||||
@@ -70,7 +70,8 @@ export function buildStaticProviderEntries(
|
|||||||
export function filterConfiguredProviderEntries<TProvider>(
|
export function filterConfiguredProviderEntries<TProvider>(
|
||||||
entries: ProviderEntry<TProvider>[],
|
entries: ProviderEntry<TProvider>[],
|
||||||
showConfiguredOnly: boolean,
|
showConfiguredOnly: boolean,
|
||||||
searchQuery?: string
|
searchQuery?: string,
|
||||||
|
showFreeOnly?: boolean
|
||||||
): ProviderEntry<TProvider>[] {
|
): ProviderEntry<TProvider>[] {
|
||||||
let filtered = entries;
|
let filtered = entries;
|
||||||
|
|
||||||
@@ -78,6 +79,13 @@ export function filterConfiguredProviderEntries<TProvider>(
|
|||||||
filtered = filtered.filter((entry) => Number(entry.stats?.total || 0) > 0);
|
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()) {
|
if (searchQuery && searchQuery.trim()) {
|
||||||
const query = searchQuery.trim().toLowerCase();
|
const query = searchQuery.trim().toLowerCase();
|
||||||
filtered = filtered.filter((entry) => {
|
filtered = filtered.filter((entry) => {
|
||||||
|
|||||||
@@ -3949,10 +3949,17 @@
|
|||||||
"toolResults": "Tool results",
|
"toolResults": "Tool results",
|
||||||
"assistantMessages": "Assistant messages",
|
"assistantMessages": "Assistant messages",
|
||||||
"codeBlocks": "Code blocks",
|
"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",
|
"maxLines": "Max lines",
|
||||||
"maxChars": "Max chars",
|
"maxChars": "Max chars",
|
||||||
"deduplicateThreshold": "Deduplicate threshold",
|
"deduplicateThreshold": "Deduplicate threshold",
|
||||||
"customFilters": "Custom filters",
|
"customFilters": "Custom filters",
|
||||||
|
"detectedType": "Detected type",
|
||||||
|
"confidence": "Confidence",
|
||||||
|
"beforeAfter": "Before / After",
|
||||||
"trustProjectFilters": "Trust project filters",
|
"trustProjectFilters": "Trust project filters",
|
||||||
"rawOutputRetention": "Raw output retention",
|
"rawOutputRetention": "Raw output retention",
|
||||||
"rawOutputNever": "Never",
|
"rawOutputNever": "Never",
|
||||||
@@ -3961,6 +3968,11 @@
|
|||||||
"rawOutputMaxBytes": "Raw output max bytes",
|
"rawOutputMaxBytes": "Raw output max bytes",
|
||||||
"filterTesting": "Filter Testing",
|
"filterTesting": "Filter Testing",
|
||||||
"pasteOutput": "Paste tool output to test filtering...",
|
"pasteOutput": "Paste tool output to test filtering...",
|
||||||
|
"presetHigh": "Aggressive",
|
||||||
|
"presetLow": "Light",
|
||||||
|
"presetMaxChars": "Max output characters",
|
||||||
|
"presetMaxLines": "Max output lines",
|
||||||
|
"presetMedium": "Standard",
|
||||||
"run": "Run",
|
"run": "Run",
|
||||||
"result": "Result",
|
"result": "Result",
|
||||||
"previewEmpty": "Run a sample to preview RTK output.",
|
"previewEmpty": "Run a sample to preview RTK output.",
|
||||||
@@ -3968,7 +3980,11 @@
|
|||||||
"tokensFiltered": "Tokens filtered",
|
"tokensFiltered": "Tokens filtered",
|
||||||
"filtersActive": "Filters active",
|
"filtersActive": "Filters active",
|
||||||
"requests": "Requests",
|
"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": {
|
"contextCombos": {
|
||||||
"title": "Compression Combos",
|
"title": "Compression Combos",
|
||||||
@@ -3995,22 +4011,41 @@
|
|||||||
"contextCaveman": {
|
"contextCaveman": {
|
||||||
"title": "Caveman Engine",
|
"title": "Caveman Engine",
|
||||||
"description": "Rule-based message compression, language packs, analytics and output mode controls.",
|
"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",
|
"requests": "Requests",
|
||||||
"tokensSaved": "Tokens saved",
|
"tokensSaved": "Tokens saved",
|
||||||
"savingsPercent": "Savings %",
|
"savingsPercent": "Savings %",
|
||||||
"avgLatency": "Avg latency",
|
"avgLatency": "Avg latency",
|
||||||
"languagePacks": "Language Packs",
|
"languagePacks": "Language Packs",
|
||||||
"languagePacksDesc": "Enable compression rules for specific languages.",
|
"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",
|
"enabled": "Enabled",
|
||||||
"autoDetect": "Auto-detect language",
|
"autoDetect": "Auto-detect language",
|
||||||
"rulesCount": "{count} rules",
|
"rulesCount": "{count} rules",
|
||||||
"analyticsTitle": "Compression Analytics",
|
"analyticsTitle": "Compression Analytics",
|
||||||
"noAnalytics": "No compression analytics yet.",
|
"noAnalytics": "No compression analytics yet.",
|
||||||
|
"outputMode": "Output Mode",
|
||||||
|
"outputModeDesc": "Control how compressed output appears",
|
||||||
"outputModeTitle": "Output Mode",
|
"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",
|
"autoClarity": "Auto-Clarity Bypass",
|
||||||
"bypassConditions": "Bypass conditions",
|
"bypassConditions": "Bypass conditions",
|
||||||
"bypassConditionsList": "security, irreversible, clarification, order-sensitive",
|
"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": {
|
"preview": {
|
||||||
"lite": "Respond concise. Preserve technical terms, code, errors, URLs, and identifiers.",
|
"lite": "Respond concise. Preserve technical terms, code, errors, URLs, and identifiers.",
|
||||||
"full": "Respond terse and compact. Preserve all technical substance.",
|
"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 FilterBar } from "./FilterBar";
|
||||||
export { default as ColumnToggle } from "./ColumnToggle";
|
export { default as ColumnToggle } from "./ColumnToggle";
|
||||||
export { default as DataTable } from "./DataTable";
|
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
|
// Layouts
|
||||||
export * from "./layouts";
|
export * from "./layouts";
|
||||||
|
|||||||
Reference in New Issue
Block a user