Add DeepSeek/xAI/OpenAI-compatible sentiment providers; custom dark dropdown
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 32s
Deploy / deploy (push) Successful in 22s

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:
2026-06-13 12:42:04 +02:00
parent d53ed972d1
commit 126c3b3c17
16 changed files with 521 additions and 98 deletions
+116
View File
@@ -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 -9
View File
@@ -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>
);
}