Add DeepSeek/xAI/OpenAI-compatible sentiment providers; custom dark dropdown
Providers (admin-switchable, no redeploy): - DeepSeek and any OpenAI-compatible endpoint (OpenRouter, Together, Groq, local Ollama) via a generic Chat Completions adapter + base_url - xAI Grok with Live Search (search_parameters web+X, citations) — grounded tier alongside OpenAI and Gemini - DeepSeek / generic compatible endpoints are ungrounded (no web search); UI shows an amber warning and labels each provider's grounding - Optional env fallbacks DEEPSEEK_API_KEY / XAI_API_KEY UI: replace native <select> (unstyleable white popup on Windows) with a custom dark Dropdown component everywhere — sentiment provider, scanner filters, market sort, indicators, admin universe, user role. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export interface DropdownOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface DropdownProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: DropdownOption[];
|
||||
id?: string;
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully dark, custom dropdown — replaces native <select>, whose popup list
|
||||
* can't be reliably styled (white background on Windows). Button + absolutely
|
||||
* positioned menu, click-outside and Escape to close.
|
||||
*/
|
||||
export function Dropdown({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
id,
|
||||
className = '',
|
||||
ariaLabel,
|
||||
placeholder = 'Select…',
|
||||
}: DropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selected = options.find((o) => o.value === value);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDocClick);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDocClick);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={`relative ${className}`}>
|
||||
<button
|
||||
type="button"
|
||||
id={id}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={ariaLabel}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="input-glass flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm"
|
||||
>
|
||||
<span className={selected ? 'text-gray-200' : 'text-gray-500'}>
|
||||
{selected ? selected.label : placeholder}
|
||||
</span>
|
||||
<svg
|
||||
className={`h-4 w-4 shrink-0 text-gray-500 transition-transform duration-150 ${open ? 'rotate-180' : ''}`}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M6 8l4 4 4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<ul
|
||||
role="listbox"
|
||||
className="absolute z-50 mt-1 max-h-64 w-full overflow-auto rounded-lg border border-white/[0.1] bg-[#151911] p-1 shadow-2xl shadow-black/50"
|
||||
>
|
||||
{options.map((opt) => {
|
||||
const isSelected = opt.value === value;
|
||||
return (
|
||||
<li key={opt.value} role="option" aria-selected={isSelected}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(opt.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors duration-100 ${
|
||||
isSelected
|
||||
? 'bg-blue-400/15 text-blue-200'
|
||||
: 'text-gray-300 hover:bg-white/[0.06] hover:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span>{opt.label}</span>
|
||||
{isSelected && (
|
||||
<svg className="h-3.5 w-3.5 text-blue-300" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.7 5.3a1 1 0 010 1.4l-7.5 7.5a1 1 0 01-1.4 0L3.3 9.7a1 1 0 011.4-1.4l3.3 3.3 6.8-6.8a1 1 0 011.4 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from 'react';
|
||||
import type { InputHTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
interface FieldProps {
|
||||
label: string;
|
||||
@@ -21,11 +21,3 @@ export function Field({ label, htmlFor, children }: FieldProps) {
|
||||
export function Input({ className = '', ...rest }: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return <input className={`input-glass px-3 py-1.5 text-sm ${className}`} {...rest} />;
|
||||
}
|
||||
|
||||
export function Select({ className = '', children, ...rest }: SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return (
|
||||
<select className={`input-glass px-3 py-1.5 text-sm [&>option]:bg-[#151911] ${className}`} {...rest}>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user