first commit
This commit is contained in:
36
frontend/src/components/admin/DataCleanup.tsx
Normal file
36
frontend/src/components/admin/DataCleanup.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useState } from 'react';
|
||||
import { useCleanupData } from '../../hooks/useAdmin';
|
||||
|
||||
export function DataCleanup() {
|
||||
const [days, setDays] = useState(90);
|
||||
const cleanup = useCleanupData();
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (days < 1) return;
|
||||
cleanup.mutate(days);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="glass p-5 flex flex-wrap items-end gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="cleanup-days" className="text-xs text-gray-400">Older than (days)</label>
|
||||
<input
|
||||
id="cleanup-days"
|
||||
type="number"
|
||||
min={1}
|
||||
value={days}
|
||||
onChange={(e) => setDays(Number(e.target.value))}
|
||||
className="w-28 input-glass px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={cleanup.isPending || days < 1}
|
||||
className="rounded-lg bg-gradient-to-r from-red-600 to-red-500 px-4 py-2 text-sm font-medium text-white hover:from-red-500 hover:to-red-400 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
|
||||
>
|
||||
{cleanup.isPending ? 'Cleaning…' : 'Run Cleanup'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
82
frontend/src/components/admin/JobControls.tsx
Normal file
82
frontend/src/components/admin/JobControls.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useJobs, useToggleJob, useTriggerJob } from '../../hooks/useAdmin';
|
||||
import { SkeletonTable } from '../ui/Skeleton';
|
||||
|
||||
function formatNextRun(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const diffMs = d.getTime() - now.getTime();
|
||||
if (diffMs < 0) return 'imminent';
|
||||
const mins = Math.round(diffMs / 60_000);
|
||||
if (mins < 60) return `in ${mins}m`;
|
||||
const hrs = Math.round(mins / 60);
|
||||
return `in ${hrs}h`;
|
||||
}
|
||||
|
||||
export function JobControls() {
|
||||
const { data: jobs, isLoading } = useJobs();
|
||||
const toggleJob = useToggleJob();
|
||||
const triggerJob = useTriggerJob();
|
||||
|
||||
if (isLoading) return <SkeletonTable rows={4} cols={3} />;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{jobs?.map((job) => (
|
||||
<div key={job.name} className="glass p-4 glass-hover">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Status dot */}
|
||||
<span
|
||||
className={`inline-block h-2.5 w-2.5 rounded-full shrink-0 ${
|
||||
job.enabled
|
||||
? 'bg-emerald-400 shadow-lg shadow-emerald-400/40'
|
||||
: 'bg-gray-500'
|
||||
}`}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-200">{job.label}</span>
|
||||
<div className="flex items-center gap-3 mt-0.5">
|
||||
<span className={`text-[11px] font-medium ${job.enabled ? 'text-emerald-400' : 'text-gray-500'}`}>
|
||||
{job.enabled ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
{job.enabled && job.next_run_at && (
|
||||
<span className="text-[11px] text-gray-500">
|
||||
Next run {formatNextRun(job.next_run_at)}
|
||||
</span>
|
||||
)}
|
||||
{!job.registered && (
|
||||
<span className="text-[11px] text-red-400">Not registered</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleJob.mutate({ jobName: job.name, enabled: !job.enabled })}
|
||||
disabled={toggleJob.isPending}
|
||||
className={`rounded-lg border px-3 py-1.5 text-xs transition-all duration-200 disabled:opacity-50 ${
|
||||
job.enabled
|
||||
? 'border-red-500/20 bg-red-500/10 text-red-400 hover:bg-red-500/20'
|
||||
: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20'
|
||||
}`}
|
||||
>
|
||||
{job.enabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => triggerJob.mutate(job.name)}
|
||||
disabled={triggerJob.isPending || !job.enabled}
|
||||
className="btn-gradient px-3 py-1.5 text-xs disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span>{triggerJob.isPending ? 'Triggering…' : 'Trigger Now'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
frontend/src/components/admin/SettingsForm.tsx
Normal file
78
frontend/src/components/admin/SettingsForm.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useState } from 'react';
|
||||
import { useSettings, useUpdateSetting } from '../../hooks/useAdmin';
|
||||
import { SkeletonTable } from '../ui/Skeleton';
|
||||
import type { SystemSetting } from '../../lib/types';
|
||||
|
||||
export function SettingsForm() {
|
||||
const { data: settings, isLoading, isError, error } = useSettings();
|
||||
const updateSetting = useUpdateSetting();
|
||||
const [edits, setEdits] = useState<Record<string, string>>({});
|
||||
|
||||
function currentValue(setting: SystemSetting) {
|
||||
return edits[setting.key] ?? setting.value;
|
||||
}
|
||||
|
||||
function handleChange(key: string, value: string) {
|
||||
setEdits((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
function handleSave(key: string) {
|
||||
const value = edits[key];
|
||||
if (value === undefined) return;
|
||||
updateSetting.mutate(
|
||||
{ key, value },
|
||||
{ onSuccess: () => setEdits((prev) => { const next = { ...prev }; delete next[key]; return next; }) },
|
||||
);
|
||||
}
|
||||
|
||||
function handleToggleRegistration(current: string) {
|
||||
updateSetting.mutate({ key: 'registration', value: current === 'true' ? 'false' : 'true' });
|
||||
}
|
||||
|
||||
if (isLoading) return <SkeletonTable rows={4} cols={2} />;
|
||||
if (isError) return <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load settings'}</p>;
|
||||
if (!settings || settings.length === 0) return <p className="text-sm text-gray-500">No settings found.</p>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{settings.map((setting) => (
|
||||
<div key={setting.key} className="glass p-4 flex flex-wrap items-center gap-3 glass-hover">
|
||||
<label className="min-w-[140px] text-sm font-medium text-gray-300">{setting.key}</label>
|
||||
{setting.key === 'registration' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleRegistration(setting.value)}
|
||||
disabled={updateSetting.isPending}
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-[#0a0e1a] disabled:opacity-50 ${
|
||||
setting.value === 'true' ? 'bg-gradient-to-r from-blue-600 to-indigo-600' : 'bg-white/[0.1]'
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={setting.value === 'true'}
|
||||
aria-label={`Toggle ${setting.key}`}
|
||||
>
|
||||
<span className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform transition-transform duration-200 ${setting.value === 'true' ? 'translate-x-5' : 'translate-x-0'}`} />
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(setting)}
|
||||
onChange={(e) => handleChange(setting.key, e.target.value)}
|
||||
className="flex-1 input-glass px-3 py-2 text-sm"
|
||||
/>
|
||||
{edits[setting.key] !== undefined && edits[setting.key] !== setting.value && (
|
||||
<button
|
||||
onClick={() => handleSave(setting.key)}
|
||||
disabled={updateSetting.isPending}
|
||||
className="btn-gradient px-3 py-2 text-xs disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span>{updateSetting.isPending ? 'Saving…' : 'Save'}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
frontend/src/components/admin/TickerManagement.tsx
Normal file
92
frontend/src/components/admin/TickerManagement.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState } from 'react';
|
||||
import { useTickers, useAddTicker, useDeleteTicker } from '../../hooks/useTickers';
|
||||
import { ConfirmDialog } from '../ui/ConfirmDialog';
|
||||
import { SkeletonTable } from '../ui/Skeleton';
|
||||
import { formatDateTime } from '../../lib/format';
|
||||
|
||||
export function TickerManagement() {
|
||||
const { data: tickers, isLoading, isError, error } = useTickers();
|
||||
const addTicker = useAddTicker();
|
||||
const deleteTicker = useDeleteTicker();
|
||||
const [newSymbol, setNewSymbol] = useState('');
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
|
||||
function handleAdd(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const symbol = newSymbol.trim().toUpperCase();
|
||||
if (!symbol) return;
|
||||
addTicker.mutate(symbol, { onSuccess: () => setNewSymbol('') });
|
||||
}
|
||||
|
||||
function handleConfirmDelete() {
|
||||
if (!deleteTarget) return;
|
||||
deleteTicker.mutate(deleteTarget, { onSuccess: () => setDeleteTarget(null) });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<form onSubmit={handleAdd} className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newSymbol}
|
||||
onChange={(e) => setNewSymbol(e.target.value)}
|
||||
placeholder="Enter ticker symbol (e.g. AAPL)"
|
||||
className="flex-1 input-glass px-3 py-2.5 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={addTicker.isPending || !newSymbol.trim()}
|
||||
className="btn-gradient px-4 py-2.5 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span>{addTicker.isPending ? 'Adding…' : 'Add Ticker'}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{isLoading && <SkeletonTable rows={5} cols={3} />}
|
||||
{isError && <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load tickers'}</p>}
|
||||
|
||||
{tickers && tickers.length > 0 && (
|
||||
<div className="glass overflow-x-auto">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="border-b border-white/[0.06] text-gray-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium text-xs uppercase tracking-wider">Symbol</th>
|
||||
<th className="px-4 py-3 font-medium text-xs uppercase tracking-wider">Added</th>
|
||||
<th className="px-4 py-3 font-medium text-xs uppercase tracking-wider text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.04]">
|
||||
{tickers.map((ticker) => (
|
||||
<tr key={ticker.id} className="hover:bg-white/[0.03] transition-all duration-150">
|
||||
<td className="px-4 py-3 font-medium text-gray-100">{ticker.symbol}</td>
|
||||
<td className="px-4 py-3 text-gray-400">{formatDateTime(ticker.created_at)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => setDeleteTarget(ticker.symbol)}
|
||||
disabled={deleteTicker.isPending}
|
||||
className="rounded-lg border border-red-500/20 bg-red-500/10 px-3 py-1 text-xs text-red-400 hover:bg-red-500/20 disabled:opacity-50 transition-all duration-200"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tickers && tickers.length === 0 && (
|
||||
<p className="text-sm text-gray-500">No tickers registered yet. Add one above.</p>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
title="Delete Ticker"
|
||||
message={`Are you sure you want to delete ${deleteTarget}? This action cannot be undone.`}
|
||||
onConfirm={handleConfirmDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
frontend/src/components/admin/UserTable.tsx
Normal file
132
frontend/src/components/admin/UserTable.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react';
|
||||
import { useUsers, useCreateUser, useUpdateAccess, useResetPassword } from '../../hooks/useAdmin';
|
||||
import { SkeletonTable } from '../ui/Skeleton';
|
||||
import type { AdminUser } from '../../lib/types';
|
||||
|
||||
export function UserTable() {
|
||||
const { data: users, isLoading, isError, error } = useUsers();
|
||||
const createUser = useCreateUser();
|
||||
const updateAccess = useUpdateAccess();
|
||||
const resetPassword = useResetPassword();
|
||||
|
||||
const [newUsername, setNewUsername] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [newRole, setNewRole] = useState('user');
|
||||
const [newAccess, setNewAccess] = useState(true);
|
||||
const [resetTarget, setResetTarget] = useState<number | null>(null);
|
||||
const [resetPw, setResetPw] = useState('');
|
||||
|
||||
function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!newUsername.trim() || !newPassword.trim()) return;
|
||||
createUser.mutate(
|
||||
{ username: newUsername.trim(), password: newPassword, role: newRole, has_access: newAccess },
|
||||
{ onSuccess: () => { setNewUsername(''); setNewPassword(''); setNewRole('user'); setNewAccess(true); } },
|
||||
);
|
||||
}
|
||||
|
||||
function handleToggleAccess(user: AdminUser) {
|
||||
updateAccess.mutate({ userId: user.id, hasAccess: !user.has_access });
|
||||
}
|
||||
|
||||
function handleResetPassword(userId: number) {
|
||||
if (!resetPw.trim()) return;
|
||||
resetPassword.mutate(
|
||||
{ userId, password: resetPw },
|
||||
{ onSuccess: () => { setResetTarget(null); setResetPw(''); } },
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Create user form */}
|
||||
<form onSubmit={handleCreate} className="glass p-5 flex flex-wrap items-end gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-gray-400">Username</label>
|
||||
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)}
|
||||
placeholder="username" className="input-glass px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-gray-400">Password</label>
|
||||
<input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="password" className="input-glass px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-gray-400">Role</label>
|
||||
<select value={newRole} onChange={(e) => setNewRole(e.target.value)} className="input-glass px-3 py-2 text-sm">
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-300 pb-1">
|
||||
<input type="checkbox" checked={newAccess} onChange={(e) => setNewAccess(e.target.checked)}
|
||||
className="rounded border-white/[0.1] bg-white/[0.04] text-blue-500 focus:ring-blue-500" />
|
||||
Access
|
||||
</label>
|
||||
<button type="submit" disabled={createUser.isPending || !newUsername.trim() || !newPassword.trim()}
|
||||
className="btn-gradient px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span>{createUser.isPending ? 'Creating…' : 'Create User'}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{isLoading && <SkeletonTable rows={4} cols={4} />}
|
||||
{isError && <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load users'}</p>}
|
||||
|
||||
{users && users.length > 0 && (
|
||||
<div className="glass overflow-x-auto">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="border-b border-white/[0.06] text-gray-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium text-xs uppercase tracking-wider">Username</th>
|
||||
<th className="px-4 py-3 font-medium text-xs uppercase tracking-wider">Role</th>
|
||||
<th className="px-4 py-3 font-medium text-xs uppercase tracking-wider">Access</th>
|
||||
<th className="px-4 py-3 font-medium text-xs uppercase tracking-wider text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.04]">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-white/[0.03] transition-all duration-150">
|
||||
<td className="px-4 py-3 font-medium text-gray-100">{user.username}</td>
|
||||
<td className="px-4 py-3 text-gray-300 capitalize">{user.role}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-block h-2.5 w-2.5 rounded-full ${user.has_access ? 'bg-emerald-400 shadow-lg shadow-emerald-400/40' : 'bg-red-400 shadow-lg shadow-red-400/40'}`} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2 flex-wrap">
|
||||
<button onClick={() => handleToggleAccess(user)} disabled={updateAccess.isPending}
|
||||
className="rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-xs text-gray-300 hover:bg-white/[0.08] disabled:opacity-50 transition-all duration-200">
|
||||
{user.has_access ? 'Revoke' : 'Grant'}
|
||||
</button>
|
||||
{resetTarget === user.id ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<input type="password" value={resetPw} onChange={(e) => setResetPw(e.target.value)}
|
||||
placeholder="new password" className="w-32 input-glass px-2 py-1 text-xs" />
|
||||
<button onClick={() => handleResetPassword(user.id)}
|
||||
disabled={resetPassword.isPending || !resetPw.trim()}
|
||||
className="btn-gradient px-2 py-1 text-xs disabled:opacity-50">
|
||||
<span>Save</span>
|
||||
</button>
|
||||
<button onClick={() => { setResetTarget(null); setResetPw(''); }}
|
||||
className="rounded-lg border border-white/[0.08] bg-white/[0.04] px-2 py-1 text-xs text-gray-400 hover:bg-white/[0.08] transition-all duration-200">
|
||||
Cancel
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<button onClick={() => { setResetTarget(user.id); setResetPw(''); }}
|
||||
className="rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-xs text-gray-300 hover:bg-white/[0.08] transition-all duration-200">
|
||||
Reset Password
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{users && users.length === 0 && <p className="text-sm text-gray-500">No users found.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/auth/ProtectedRoute.tsx
Normal file
21
frontend/src/components/auth/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
requireAdmin?: boolean;
|
||||
}
|
||||
|
||||
export default function ProtectedRoute({ requireAdmin }: ProtectedRouteProps) {
|
||||
const token = useAuthStore((s) => s.token);
|
||||
const role = useAuthStore((s) => s.role);
|
||||
|
||||
if (!token) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (requireAdmin && role !== 'admin') {
|
||||
return <Navigate to="/watchlist" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
232
frontend/src/components/charts/CandlestickChart.tsx
Normal file
232
frontend/src/components/charts/CandlestickChart.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { useMemo, useRef, useEffect, useCallback } from 'react';
|
||||
import type { OHLCVBar, SRLevel } from '../../lib/types';
|
||||
import { formatPrice, formatDate } from '../../lib/format';
|
||||
|
||||
interface CandlestickChartProps {
|
||||
data: OHLCVBar[];
|
||||
srLevels?: SRLevel[];
|
||||
maxSRLevels?: number;
|
||||
}
|
||||
|
||||
function filterTopSRLevels(levels: SRLevel[], max: number): SRLevel[] {
|
||||
if (levels.length <= max) return levels;
|
||||
return [...levels].sort((a, b) => b.strength - a.strength).slice(0, max);
|
||||
}
|
||||
|
||||
interface TooltipState {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
bar: OHLCVBar | null;
|
||||
}
|
||||
|
||||
export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6 }: CandlestickChartProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const tooltipState = useRef<TooltipState>({ visible: false, x: 0, y: 0, bar: null });
|
||||
const animFrame = useRef<number>(0);
|
||||
|
||||
const topLevels = useMemo(() => filterTopSRLevels(srLevels, maxSRLevels), [srLevels, maxSRLevels]);
|
||||
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container || data.length === 0) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const W = rect.width;
|
||||
const H = 400;
|
||||
|
||||
canvas.width = W * dpr;
|
||||
canvas.height = H * dpr;
|
||||
canvas.style.width = `${W}px`;
|
||||
canvas.style.height = `${H}px`;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
// Margins
|
||||
const ml = 12, mr = 70, mt = 12, mb = 32;
|
||||
const cw = W - ml - mr;
|
||||
const ch = H - mt - mb;
|
||||
|
||||
// Price range
|
||||
const allPrices = data.flatMap((b) => [b.high, b.low]);
|
||||
const srPrices = topLevels.map((l) => l.price_level);
|
||||
const allVals = [...allPrices, ...srPrices];
|
||||
const minP = Math.min(...allVals);
|
||||
const maxP = Math.max(...allVals);
|
||||
const pad = (maxP - minP) * 0.06 || 1;
|
||||
const lo = minP - pad;
|
||||
const hi = maxP + pad;
|
||||
|
||||
const yScale = (v: number) => mt + ch - ((v - lo) / (hi - lo)) * ch;
|
||||
const barW = cw / data.length;
|
||||
const candleW = Math.max(barW * 0.65, 1);
|
||||
|
||||
// Grid lines (horizontal)
|
||||
const nTicks = 6;
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.fillStyle = '#6b7280';
|
||||
ctx.font = '11px Inter, system-ui, sans-serif';
|
||||
ctx.textAlign = 'right';
|
||||
for (let i = 0; i <= nTicks; i++) {
|
||||
const v = lo + ((hi - lo) * i) / nTicks;
|
||||
const y = yScale(v);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ml, y);
|
||||
ctx.lineTo(ml + cw, y);
|
||||
ctx.stroke();
|
||||
ctx.fillText(formatPrice(v), W - 8, y + 4);
|
||||
}
|
||||
|
||||
// X-axis labels
|
||||
ctx.textAlign = 'center';
|
||||
const labelInterval = Math.max(Math.floor(data.length / 8), 1);
|
||||
for (let i = 0; i < data.length; i += labelInterval) {
|
||||
const x = ml + i * barW + barW / 2;
|
||||
ctx.fillStyle = '#6b7280';
|
||||
ctx.fillText(formatDate(data[i].date), x, H - 6);
|
||||
}
|
||||
|
||||
// S/R levels
|
||||
topLevels.forEach((level) => {
|
||||
const y = yScale(level.price_level);
|
||||
const isSupport = level.type === 'support';
|
||||
const color = isSupport ? '#10b981' : '#ef4444';
|
||||
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.globalAlpha = 0.55;
|
||||
ctx.setLineDash([6, 3]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ml, y);
|
||||
ctx.lineTo(ml + cw, y);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Label
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = '10px Inter, system-ui, sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(
|
||||
`${level.type[0].toUpperCase()} ${formatPrice(level.price_level)}`,
|
||||
ml + cw + 4,
|
||||
y + 3
|
||||
);
|
||||
});
|
||||
|
||||
// Candles
|
||||
data.forEach((bar, i) => {
|
||||
const x = ml + i * barW + barW / 2;
|
||||
const bullish = bar.close >= bar.open;
|
||||
const color = bullish ? '#10b981' : '#ef4444';
|
||||
|
||||
const yHigh = yScale(bar.high);
|
||||
const yLow = yScale(bar.low);
|
||||
const yOpen = yScale(bar.open);
|
||||
const yClose = yScale(bar.close);
|
||||
|
||||
// Wick
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, yHigh);
|
||||
ctx.lineTo(x, yLow);
|
||||
ctx.stroke();
|
||||
|
||||
// Body
|
||||
const bodyTop = Math.min(yOpen, yClose);
|
||||
const bodyH = Math.max(Math.abs(yOpen - yClose), 1);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x - candleW / 2, bodyTop, candleW, bodyH);
|
||||
});
|
||||
|
||||
// Store geometry for hit testing
|
||||
(canvas as any).__chartMeta = { ml, mr, mt, mb, cw, ch, barW, lo, hi, yScale };
|
||||
}, [data, topLevels]);
|
||||
|
||||
useEffect(() => {
|
||||
draw();
|
||||
const onResize = () => {
|
||||
cancelAnimationFrame(animFrame.current);
|
||||
animFrame.current = requestAnimationFrame(draw);
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
cancelAnimationFrame(animFrame.current);
|
||||
};
|
||||
}, [draw]);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const canvas = canvasRef.current;
|
||||
const tip = tooltipRef.current;
|
||||
if (!canvas || !tip || data.length === 0) return;
|
||||
|
||||
const meta = (canvas as any).__chartMeta;
|
||||
if (!meta) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const idx = Math.floor((mx - meta.ml) / meta.barW);
|
||||
|
||||
if (idx >= 0 && idx < data.length) {
|
||||
const bar = data[idx];
|
||||
tooltipState.current = { visible: true, x: e.clientX - rect.left, y: e.clientY - rect.top, bar };
|
||||
tip.style.display = 'block';
|
||||
tip.style.left = `${Math.min(mx + 14, rect.width - 180)}px`;
|
||||
tip.style.top = `${Math.max(e.clientY - rect.top - 80, 8)}px`;
|
||||
tip.innerHTML = `
|
||||
<div class="text-gray-300 font-medium mb-1">${formatDate(bar.date)}</div>
|
||||
<div class="grid grid-cols-2 gap-x-3 gap-y-0.5 text-gray-400">
|
||||
<span>Open</span><span class="text-right text-gray-200">${formatPrice(bar.open)}</span>
|
||||
<span>High</span><span class="text-right text-gray-200">${formatPrice(bar.high)}</span>
|
||||
<span>Low</span><span class="text-right text-gray-200">${formatPrice(bar.low)}</span>
|
||||
<span>Close</span><span class="text-right text-gray-200">${formatPrice(bar.close)}</span>
|
||||
<span>Vol</span><span class="text-right text-gray-200">${bar.volume.toLocaleString()}</span>
|
||||
</div>`;
|
||||
} else {
|
||||
tip.style.display = 'none';
|
||||
}
|
||||
},
|
||||
[data]
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
const tip = tooltipRef.current;
|
||||
if (tip) tip.style.display = 'none';
|
||||
}, []);
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center text-gray-500">
|
||||
No OHLCV data available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full" style={{ height: 400 }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full cursor-crosshair"
|
||||
style={{ height: 400 }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className="glass absolute pointer-events-none px-3 py-2 text-xs shadow-2xl z-50"
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
frontend/src/components/layout/AppShell.tsx
Normal file
17
frontend/src/components/layout/AppShell.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Sidebar from './Sidebar';
|
||||
import MobileNav from './MobileNav';
|
||||
|
||||
export default function AppShell() {
|
||||
return (
|
||||
<div className="flex min-h-screen text-gray-100">
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col">
|
||||
<MobileNav />
|
||||
<main className="flex-1 p-4 lg:p-8 animate-fade-in">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
frontend/src/components/layout/MobileNav.tsx
Normal file
88
frontend/src/components/layout/MobileNav.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/watchlist', label: 'Watchlist' },
|
||||
{ to: '/scanner', label: 'Scanner' },
|
||||
{ to: '/rankings', label: 'Rankings' },
|
||||
];
|
||||
|
||||
export default function MobileNav() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { role, username, logout } = useAuthStore();
|
||||
|
||||
return (
|
||||
<div className="lg:hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 glass rounded-none border-x-0 border-t-0">
|
||||
<h1 className="text-lg font-semibold text-gradient">Signal Dashboard</h1>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="p-2 text-gray-400 hover:text-gray-200 transition-colors duration-200"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{open ? (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-300 ease-out glass rounded-none border-x-0 border-t-0 ${
|
||||
open ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0 border-b-0'
|
||||
}`}
|
||||
>
|
||||
<nav className="px-3 py-2 space-y-1">
|
||||
{navItems.map(({ to, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
onClick={() => setOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
`block px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-white/[0.08] text-white'
|
||||
: 'text-gray-400 hover:bg-white/[0.04] hover:text-gray-200'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
{role === 'admin' && (
|
||||
<NavLink
|
||||
to="/admin"
|
||||
onClick={() => setOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
`block px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-white/[0.08] text-white'
|
||||
: 'text-gray-400 hover:bg-white/[0.04] hover:text-gray-200'
|
||||
}`
|
||||
}
|
||||
>
|
||||
Admin
|
||||
</NavLink>
|
||||
)}
|
||||
</nav>
|
||||
<div className="px-4 py-3 border-t border-white/[0.06]">
|
||||
{username && (
|
||||
<p className="text-xs text-gray-500 mb-2 truncate">{username}</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { logout(); setOpen(false); }}
|
||||
className="w-full text-left px-3 py-2 text-sm text-gray-400 hover:text-gray-200 hover:bg-white/[0.04] rounded-lg transition-all duration-200"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
frontend/src/components/layout/Sidebar.tsx
Normal file
90
frontend/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { check as healthCheck } from '../../api/health';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/watchlist', label: 'Watchlist', icon: '◈' },
|
||||
{ to: '/scanner', label: 'Scanner', icon: '⬡' },
|
||||
{ to: '/rankings', label: 'Rankings', icon: '△' },
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const { role, username, logout } = useAuthStore();
|
||||
|
||||
const health = useQuery({
|
||||
queryKey: ['health'],
|
||||
queryFn: healthCheck,
|
||||
refetchInterval: 30_000,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const isBackendUp = health.isSuccess;
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:flex lg:flex-col lg:w-64 h-screen sticky top-0 glass border-r border-white/[0.06] rounded-none border-l-0 border-t-0 border-b-0">
|
||||
{/* Logo area */}
|
||||
<div className="px-6 py-6 border-b border-white/[0.06]">
|
||||
<h1 className="text-lg font-semibold text-gradient">Signal Dashboard</h1>
|
||||
<p className="text-[11px] text-gray-500 mt-0.5 tracking-wide">TRADING INTELLIGENCE</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-3 py-5 space-y-1">
|
||||
{navItems.map(({ to, label, icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-white/[0.08] text-white shadow-lg shadow-blue-500/5 border border-white/[0.08]'
|
||||
: 'text-gray-400 hover:bg-white/[0.04] hover:text-gray-200 border border-transparent'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="text-base opacity-60">{icon}</span>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
{role === 'admin' && (
|
||||
<NavLink
|
||||
to="/admin"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-white/[0.08] text-white shadow-lg shadow-blue-500/5 border border-white/[0.08]'
|
||||
: 'text-gray-400 hover:bg-white/[0.04] hover:text-gray-200 border border-transparent'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="text-base opacity-60">⚙</span>
|
||||
Admin
|
||||
</NavLink>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="px-4 py-4 border-t border-white/[0.06] space-y-3">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${
|
||||
isBackendUp ? 'bg-emerald-400 shadow-lg shadow-emerald-400/50' : 'bg-red-400 shadow-lg shadow-red-400/50'
|
||||
}`}
|
||||
aria-label={isBackendUp ? 'Backend online' : 'Backend offline'}
|
||||
/>
|
||||
<span className="text-xs text-gray-500">
|
||||
{isBackendUp ? 'Backend online' : 'Backend offline'}
|
||||
</span>
|
||||
</div>
|
||||
{username && (
|
||||
<p className="text-xs text-gray-500 truncate px-1">{username}</p>
|
||||
)}
|
||||
<button
|
||||
onClick={logout}
|
||||
className="w-full px-3 py-2 text-sm text-gray-400 hover:text-gray-200 hover:bg-white/[0.04] rounded-lg transition-all duration-200"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
57
frontend/src/components/rankings/RankingsTable.tsx
Normal file
57
frontend/src/components/rankings/RankingsTable.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { RankingEntry } from '../../lib/types';
|
||||
|
||||
interface RankingsTableProps {
|
||||
rankings: RankingEntry[];
|
||||
}
|
||||
|
||||
function scoreColor(score: number): string {
|
||||
if (score > 70) return 'text-emerald-400';
|
||||
if (score >= 40) return 'text-amber-400';
|
||||
return 'text-red-400';
|
||||
}
|
||||
|
||||
export function RankingsTable({ rankings }: RankingsTableProps) {
|
||||
if (rankings.length === 0) {
|
||||
return <p className="py-8 text-center text-sm text-gray-500">No rankings available.</p>;
|
||||
}
|
||||
|
||||
const dimensionNames = rankings.length > 0 ? rankings[0].dimensions.map((d) => d.dimension) : [];
|
||||
|
||||
return (
|
||||
<div className="glass overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06] text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="px-4 py-3">Rank</th>
|
||||
<th className="px-4 py-3">Symbol</th>
|
||||
<th className="px-4 py-3">Composite</th>
|
||||
{dimensionNames.map((dim) => (
|
||||
<th key={dim} className="px-4 py-3">{dim}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rankings.map((entry, index) => (
|
||||
<tr key={entry.symbol} className="border-b border-white/[0.04] transition-all duration-200 hover:bg-white/[0.03]">
|
||||
<td className="px-4 py-3.5 font-mono text-gray-500">{index + 1}</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<Link to={`/ticker/${entry.symbol}`} className="font-medium text-blue-400 hover:text-blue-300 transition-colors duration-150">
|
||||
{entry.symbol}
|
||||
</Link>
|
||||
</td>
|
||||
<td className={`px-4 py-3.5 font-semibold ${scoreColor(entry.composite_score)}`}>
|
||||
{Math.round(entry.composite_score)}
|
||||
</td>
|
||||
{entry.dimensions.map((dim) => (
|
||||
<td key={dim.dimension} className={`px-4 py-3.5 font-mono ${scoreColor(dim.score)}`}>
|
||||
{Math.round(dim.score)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
frontend/src/components/rankings/WeightsForm.tsx
Normal file
50
frontend/src/components/rankings/WeightsForm.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useUpdateWeights } from '../../hooks/useScores';
|
||||
|
||||
interface WeightsFormProps {
|
||||
weights: Record<string, number>;
|
||||
}
|
||||
|
||||
export function WeightsForm({ weights }: WeightsFormProps) {
|
||||
const [localWeights, setLocalWeights] = useState<Record<string, number>>(weights);
|
||||
const updateWeights = useUpdateWeights();
|
||||
|
||||
const handleChange = (key: string, value: string) => {
|
||||
const num = parseFloat(value);
|
||||
if (!isNaN(num)) setLocalWeights((prev) => ({ ...prev, [key]: num }));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
updateWeights.mutate(localWeights);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="glass p-5">
|
||||
<h3 className="mb-4 text-xs font-semibold uppercase tracking-widest text-gray-500">
|
||||
Scoring Weights
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{Object.keys(weights).map((key) => (
|
||||
<label key={key} className="flex flex-col gap-1.5">
|
||||
<span className="text-xs text-gray-400 capitalize">{key.replace(/_/g, ' ')}</span>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={localWeights[key] ?? 0}
|
||||
onChange={(e) => handleChange(key, e.target.value)}
|
||||
className="input-glass px-2.5 py-1.5 text-sm"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateWeights.isPending}
|
||||
className="mt-4 btn-gradient px-4 py-2 text-sm disabled:opacity-50"
|
||||
>
|
||||
<span>{updateWeights.isPending ? 'Updating…' : 'Update Weights'}</span>
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
81
frontend/src/components/scanner/TradeTable.tsx
Normal file
81
frontend/src/components/scanner/TradeTable.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { TradeSetup } from '../../lib/types';
|
||||
import { formatPrice, formatDateTime } from '../../lib/format';
|
||||
|
||||
export type SortColumn = 'symbol' | 'direction' | 'entry_price' | 'stop_loss' | 'target' | 'rr_ratio' | 'composite_score' | 'detected_at';
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
|
||||
interface TradeTableProps {
|
||||
trades: TradeSetup[];
|
||||
sortColumn: SortColumn;
|
||||
sortDirection: SortDirection;
|
||||
onSort: (column: SortColumn) => void;
|
||||
}
|
||||
|
||||
const columns: { key: SortColumn; label: string }[] = [
|
||||
{ key: 'symbol', label: 'Symbol' },
|
||||
{ key: 'direction', label: 'Direction' },
|
||||
{ key: 'entry_price', label: 'Entry' },
|
||||
{ key: 'stop_loss', label: 'Stop Loss' },
|
||||
{ key: 'target', label: 'Target' },
|
||||
{ key: 'rr_ratio', label: 'R:R' },
|
||||
{ key: 'composite_score', label: 'Score' },
|
||||
{ key: 'detected_at', label: 'Detected' },
|
||||
];
|
||||
|
||||
function sortIndicator(column: SortColumn, active: SortColumn, dir: SortDirection) {
|
||||
if (column !== active) return '';
|
||||
return dir === 'asc' ? ' ▲' : ' ▼';
|
||||
}
|
||||
|
||||
export function TradeTable({ trades, sortColumn, sortDirection, onSort }: TradeTableProps) {
|
||||
if (trades.length === 0) {
|
||||
return <p className="py-8 text-center text-sm text-gray-500">No trade setups match the current filters.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06] text-xs uppercase tracking-wider text-gray-500">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className="cursor-pointer select-none px-4 py-3 hover:text-gray-300 transition-colors duration-150"
|
||||
onClick={() => onSort(col.key)}
|
||||
>
|
||||
{col.label}{sortIndicator(col.key, sortColumn, sortDirection)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trades.map((trade) => (
|
||||
<tr key={trade.id} className="border-b border-white/[0.04] transition-all duration-200 hover:bg-white/[0.03]">
|
||||
<td className="px-4 py-3.5">
|
||||
<Link to={`/ticker/${trade.symbol}`} className="font-medium text-blue-400 hover:text-blue-300 transition-colors duration-150">
|
||||
{trade.symbol}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<span className={trade.direction === 'long' ? 'font-medium text-emerald-400' : 'font-medium text-red-400'}>
|
||||
{trade.direction}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(trade.entry_price)}</td>
|
||||
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(trade.stop_loss)}</td>
|
||||
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(trade.target)}</td>
|
||||
<td className="px-4 py-3.5 font-mono font-semibold text-gray-200">{trade.rr_ratio.toFixed(2)}</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<span className={`font-semibold ${trade.composite_score > 70 ? 'text-emerald-400' : trade.composite_score >= 40 ? 'text-amber-400' : 'text-red-400'}`}>
|
||||
{Math.round(trade.composite_score)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-gray-400">{formatDateTime(trade.detected_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/ticker/FundamentalsPanel.tsx
Normal file
34
frontend/src/components/ticker/FundamentalsPanel.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { formatPercent, formatLargeNumber } from '../../lib/format';
|
||||
import type { FundamentalResponse } from '../../lib/types';
|
||||
|
||||
interface FundamentalsPanelProps {
|
||||
data: FundamentalResponse;
|
||||
}
|
||||
|
||||
export function FundamentalsPanel({ data }: FundamentalsPanelProps) {
|
||||
const items = [
|
||||
{ label: 'P/E Ratio', value: data.pe_ratio !== null ? data.pe_ratio.toFixed(2) : '—' },
|
||||
{ label: 'Revenue Growth', value: data.revenue_growth !== null ? formatPercent(data.revenue_growth) : '—' },
|
||||
{ label: 'Earnings Surprise', value: data.earnings_surprise !== null ? formatPercent(data.earnings_surprise) : '—' },
|
||||
{ label: 'Market Cap', value: data.market_cap !== null ? formatLargeNumber(data.market_cap) : '—' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="glass p-5">
|
||||
<h3 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Fundamentals</h3>
|
||||
<div className="space-y-2.5 text-sm">
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="flex justify-between">
|
||||
<span className="text-gray-400">{item.label}</span>
|
||||
<span className="text-gray-200">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
{data.fetched_at && (
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
Updated {new Date(data.fetched_at).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
133
frontend/src/components/ticker/IndicatorSelector.tsx
Normal file
133
frontend/src/components/ticker/IndicatorSelector.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getIndicator, getEMACross } from '../../api/indicators';
|
||||
import type { IndicatorResult, EMACrossResult } from '../../lib/types';
|
||||
|
||||
const INDICATOR_TYPES = ['ADX', 'EMA', 'RSI', 'ATR', 'volume_profile', 'pivot_points'] as const;
|
||||
|
||||
interface IndicatorSelectorProps {
|
||||
symbol: string;
|
||||
}
|
||||
|
||||
const signalColors: Record<string, string> = {
|
||||
bullish: 'text-emerald-400',
|
||||
bearish: 'text-red-400',
|
||||
neutral: 'text-gray-300',
|
||||
};
|
||||
|
||||
function IndicatorResultDisplay({ result }: { result: IndicatorResult }) {
|
||||
return (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Type</span>
|
||||
<span className="text-gray-200">{result.indicator_type}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Normalized Score</span>
|
||||
<span className="text-gray-200">{result.score.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Bars Used</span>
|
||||
<span className="text-gray-200">{result.bars_used}</span>
|
||||
</div>
|
||||
{Object.keys(result.values).length > 0 && (
|
||||
<div className="mt-2 border-t border-white/[0.06] pt-2">
|
||||
<p className="mb-1 text-[10px] font-medium uppercase tracking-widest text-gray-500">Values</p>
|
||||
{Object.entries(result.values).map(([key, val]) => (
|
||||
<div key={key} className="flex justify-between">
|
||||
<span className="text-gray-400">{key}</span>
|
||||
<span className="text-gray-200">{typeof val === 'number' ? val.toFixed(4) : String(val)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EMACrossDisplay({ result }: { result: EMACrossResult }) {
|
||||
return (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Signal</span>
|
||||
<span className={signalColors[result.signal] ?? 'text-gray-300'}>{result.signal}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Short EMA ({result.short_period})</span>
|
||||
<span className="text-gray-200">{result.short_ema.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Long EMA ({result.long_period})</span>
|
||||
<span className="text-gray-200">{result.long_ema.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function IndicatorSelector({ symbol }: IndicatorSelectorProps) {
|
||||
const [selectedType, setSelectedType] = useState<string>('');
|
||||
const [showEMACross, setShowEMACross] = useState(false);
|
||||
|
||||
const indicatorQuery = useQuery({
|
||||
queryKey: ['indicator', symbol, selectedType],
|
||||
queryFn: () => getIndicator(symbol, selectedType),
|
||||
enabled: !!symbol && !!selectedType,
|
||||
});
|
||||
|
||||
const emaCrossQuery = useQuery({
|
||||
queryKey: ['ema-cross', symbol],
|
||||
queryFn: () => getEMACross(symbol),
|
||||
enabled: !!symbol && showEMACross,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="glass p-5">
|
||||
<h3 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Indicators</h3>
|
||||
|
||||
<div className="mb-4">
|
||||
<select
|
||||
value={selectedType}
|
||||
onChange={(e) => setSelectedType(e.target.value)}
|
||||
className="w-full input-glass px-3 py-2.5 text-sm"
|
||||
>
|
||||
<option value="">Select indicator…</option>
|
||||
{INDICATOR_TYPES.map((type) => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedType && indicatorQuery.isLoading && (
|
||||
<div className="animate-pulse space-y-2">
|
||||
<div className="h-4 w-3/4 rounded bg-white/[0.05]" />
|
||||
<div className="h-4 w-1/2 rounded bg-white/[0.05]" />
|
||||
<div className="h-4 w-2/3 rounded bg-white/[0.05]" />
|
||||
</div>
|
||||
)}
|
||||
{selectedType && indicatorQuery.isError && (
|
||||
<p className="text-sm text-red-400">
|
||||
{indicatorQuery.error instanceof Error ? indicatorQuery.error.message : 'Failed to load indicator'}
|
||||
</p>
|
||||
)}
|
||||
{indicatorQuery.data && <IndicatorResultDisplay result={indicatorQuery.data} />}
|
||||
|
||||
<div className="mt-4 border-t border-white/[0.06] pt-4">
|
||||
<button
|
||||
onClick={() => setShowEMACross(true)}
|
||||
disabled={showEMACross && emaCrossQuery.isLoading}
|
||||
className="w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2.5 text-sm text-gray-200 transition-all duration-200 hover:bg-white/[0.07] disabled:opacity-50"
|
||||
>
|
||||
{emaCrossQuery.isLoading ? 'Loading EMA Cross…' : 'Show EMA Cross Signal'}
|
||||
</button>
|
||||
{emaCrossQuery.isError && (
|
||||
<p className="mt-2 text-sm text-red-400">
|
||||
{emaCrossQuery.error instanceof Error ? emaCrossQuery.error.message : 'Failed to load EMA cross'}
|
||||
</p>
|
||||
)}
|
||||
{emaCrossQuery.data && (
|
||||
<div className="mt-3"><EMACrossDisplay result={emaCrossQuery.data} /></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
frontend/src/components/ticker/SROverlay.tsx
Normal file
32
frontend/src/components/ticker/SROverlay.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ReferenceLine } from 'recharts';
|
||||
import type { SRLevel } from '../../lib/types';
|
||||
import { formatPrice } from '../../lib/format';
|
||||
|
||||
interface SROverlayProps {
|
||||
levels: SRLevel[];
|
||||
}
|
||||
|
||||
export function SROverlay({ levels }: SROverlayProps) {
|
||||
return (
|
||||
<>
|
||||
{levels.map((level) => {
|
||||
const isSupport = level.type === 'support';
|
||||
return (
|
||||
<ReferenceLine
|
||||
key={level.id}
|
||||
y={level.price_level}
|
||||
stroke={isSupport ? '#22c55e' : '#ef4444'}
|
||||
strokeDasharray="6 3"
|
||||
strokeWidth={1.5}
|
||||
label={{
|
||||
value: formatPrice(level.price_level),
|
||||
position: 'right',
|
||||
fill: isSupport ? '#22c55e' : '#ef4444',
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
46
frontend/src/components/ticker/SentimentPanel.tsx
Normal file
46
frontend/src/components/ticker/SentimentPanel.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { formatPercent } from '../../lib/format';
|
||||
import type { SentimentResponse } from '../../lib/types';
|
||||
|
||||
interface SentimentPanelProps {
|
||||
data: SentimentResponse;
|
||||
}
|
||||
|
||||
const classificationColors: Record<string, string> = {
|
||||
bullish: 'text-emerald-400',
|
||||
bearish: 'text-red-400',
|
||||
neutral: 'text-gray-300',
|
||||
};
|
||||
|
||||
export function SentimentPanel({ data }: SentimentPanelProps) {
|
||||
const latest = data.scores[0];
|
||||
|
||||
return (
|
||||
<div className="glass p-5">
|
||||
<h3 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Sentiment</h3>
|
||||
{latest ? (
|
||||
<div className="space-y-2.5 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Classification</span>
|
||||
<span className={classificationColors[latest.classification] ?? 'text-gray-300'}>
|
||||
{latest.classification}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Confidence</span>
|
||||
<span className="text-gray-200">{formatPercent(latest.confidence)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Dimension Score</span>
|
||||
<span className="text-gray-200">{data.dimension_score !== null ? Math.round(data.dimension_score) : '—'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Sources</span>
|
||||
<span className="text-gray-200">{data.count}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">No sentiment data available</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
frontend/src/components/ui/Badge.tsx
Normal file
18
frontend/src/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
const variantStyles: Record<string, string> = {
|
||||
auto: 'bg-blue-500/15 text-blue-400 border-blue-500/20',
|
||||
manual: 'bg-violet-500/15 text-violet-400 border-violet-500/20',
|
||||
default: 'bg-white/[0.06] text-gray-400 border-white/[0.08]',
|
||||
};
|
||||
|
||||
interface BadgeProps {
|
||||
label: string;
|
||||
variant?: 'auto' | 'manual' | 'default';
|
||||
}
|
||||
|
||||
export function Badge({ label, variant = 'default' }: BadgeProps) {
|
||||
return (
|
||||
<span className={`inline-block rounded-full border px-2.5 py-0.5 text-xs font-medium backdrop-blur-sm ${variantStyles[variant]}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
35
frontend/src/components/ui/ConfirmDialog.tsx
Normal file
35
frontend/src/components/ui/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({ open, title, message, onConfirm, onCancel }: ConfirmDialogProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center animate-fade-in">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onCancel} />
|
||||
<div className="relative z-10 w-full max-w-md glass p-6 shadow-2xl animate-slide-up">
|
||||
<h2 className="text-lg font-semibold text-gray-100">{title}</h2>
|
||||
<p className="mt-2 text-sm text-gray-400">{message}</p>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="rounded-lg border border-white/[0.1] bg-white/[0.04] px-4 py-2 text-sm text-gray-300 hover:bg-white/[0.08] transition-all duration-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="rounded-lg bg-gradient-to-r from-red-600 to-red-500 px-4 py-2 text-sm text-white hover:from-red-500 hover:to-red-400 transition-all duration-200"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
frontend/src/components/ui/ScoreCard.tsx
Normal file
89
frontend/src/components/ui/ScoreCard.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
interface ScoreCardProps {
|
||||
compositeScore: number | null;
|
||||
dimensions: { dimension: string; score: number }[];
|
||||
}
|
||||
|
||||
function scoreColor(score: number): string {
|
||||
if (score > 70) return 'text-emerald-400';
|
||||
if (score >= 40) return 'text-amber-400';
|
||||
return 'text-red-400';
|
||||
}
|
||||
|
||||
function ringGradient(score: number): string {
|
||||
if (score > 70) return '#10b981';
|
||||
if (score >= 40) return '#f59e0b';
|
||||
return '#ef4444';
|
||||
}
|
||||
|
||||
function barGradient(score: number): string {
|
||||
if (score > 70) return 'from-emerald-500 to-emerald-400';
|
||||
if (score >= 40) return 'from-amber-500 to-amber-400';
|
||||
return 'from-red-500 to-red-400';
|
||||
}
|
||||
|
||||
function ScoreRing({ score }: { score: number }) {
|
||||
const radius = 36;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const clamped = Math.max(0, Math.min(100, score));
|
||||
const offset = circumference - (clamped / 100) * circumference;
|
||||
const color = ringGradient(score);
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex items-center justify-center">
|
||||
<svg width="88" height="88" className="-rotate-90">
|
||||
<circle cx="44" cy="44" r={radius} fill="none" strokeWidth="6" className="stroke-white/[0.06]" />
|
||||
<circle
|
||||
cx="44" cy="44" r={radius} fill="none" strokeWidth="6" strokeLinecap="round"
|
||||
strokeDasharray={circumference} strokeDashoffset={offset}
|
||||
stroke={color}
|
||||
style={{ filter: `drop-shadow(0 0 8px ${color}40)`, transition: 'all 0.6s ease' }}
|
||||
/>
|
||||
</svg>
|
||||
<span className={`absolute text-lg font-bold ${scoreColor(score)}`}>
|
||||
{Math.round(score)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScoreCard({ compositeScore, dimensions }: ScoreCardProps) {
|
||||
return (
|
||||
<div className="glass p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
{compositeScore !== null ? (
|
||||
<ScoreRing score={compositeScore} />
|
||||
) : (
|
||||
<div className="flex h-[88px] w-[88px] items-center justify-center text-sm text-gray-500">N/A</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider">Composite Score</p>
|
||||
<p className={`text-2xl font-bold ${compositeScore !== null ? scoreColor(compositeScore) : 'text-gray-500'}`}>
|
||||
{compositeScore !== null ? Math.round(compositeScore) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dimensions.length > 0 && (
|
||||
<div className="mt-5 space-y-2.5">
|
||||
<p className="text-[10px] font-medium uppercase tracking-widest text-gray-500">Dimensions</p>
|
||||
{dimensions.map((d) => (
|
||||
<div key={d.dimension} className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-300 capitalize">{d.dimension}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-20 rounded-full bg-white/[0.06] overflow-hidden">
|
||||
<div
|
||||
className={`h-1.5 rounded-full bg-gradient-to-r ${barGradient(d.score)} transition-all duration-500`}
|
||||
style={{ width: `${Math.max(0, Math.min(100, d.score))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`w-8 text-right font-medium text-xs ${scoreColor(d.score)}`}>
|
||||
{Math.round(d.score)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/ui/Skeleton.tsx
Normal file
23
frontend/src/components/ui/Skeleton.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
const pulse = 'animate-pulse rounded-lg bg-white/[0.05]';
|
||||
|
||||
export function SkeletonLine({ className = '' }: { className?: string }) {
|
||||
return <div className={`${pulse} h-4 w-full ${className}`} />;
|
||||
}
|
||||
|
||||
export function SkeletonCard({ className = '' }: { className?: string }) {
|
||||
return <div className={`${pulse} h-32 w-full ${className}`} />;
|
||||
}
|
||||
|
||||
export function SkeletonTable({ rows = 5, cols = 4, className = '' }: { rows?: number; cols?: number; className?: string }) {
|
||||
return (
|
||||
<div className={`space-y-2 ${className}`}>
|
||||
{Array.from({ length: rows }, (_, r) => (
|
||||
<div key={r} className="flex gap-4">
|
||||
{Array.from({ length: cols }, (_, c) => (
|
||||
<div key={c} className={`${pulse} h-6 flex-1`} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
frontend/src/components/ui/Toast.tsx
Normal file
87
frontend/src/components/ui/Toast.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info';
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
addToast: (type: ToastType, message: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
const MAX_VISIBLE = 3;
|
||||
const AUTO_DISMISS_MS = 8000;
|
||||
|
||||
const typeStyles: Record<ToastType, string> = {
|
||||
error: 'border-red-500/30 bg-red-500/10 text-red-300',
|
||||
success: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300',
|
||||
info: 'border-blue-500/30 bg-blue-500/10 text-blue-300',
|
||||
};
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const nextId = useRef(0);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const addToast = useCallback((type: ToastType, message: string) => {
|
||||
const id = String(nextId.current++);
|
||||
setToasts((prev) => {
|
||||
const next = [...prev, { id, type, message }];
|
||||
return next.length > MAX_VISIBLE ? next.slice(next.length - MAX_VISIBLE) : next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ addToast }}>
|
||||
{children}
|
||||
{createPortal(
|
||||
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 w-96">
|
||||
{toasts.map((toast) => (
|
||||
<ToastItem key={toast.id} toast={toast} onDismiss={removeToast} />
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: string) => void }) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => onDismiss(toast.id), AUTO_DISMISS_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [toast.id, onDismiss]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={`glass px-4 py-3 shadow-2xl animate-slide-up ${typeStyles[toast.type]}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm">{toast.message}</p>
|
||||
<button
|
||||
onClick={() => onDismiss(toast.id)}
|
||||
className="shrink-0 text-gray-400 hover:text-gray-200 transition-colors duration-200"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast(): ToastContextValue {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error('useToast must be used within a ToastProvider');
|
||||
return ctx;
|
||||
}
|
||||
33
frontend/src/components/watchlist/AddTickerForm.tsx
Normal file
33
frontend/src/components/watchlist/AddTickerForm.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { useAddToWatchlist } from '../../hooks/useWatchlist';
|
||||
|
||||
export function AddTickerForm() {
|
||||
const [symbol, setSymbol] = useState('');
|
||||
const addMutation = useAddToWatchlist();
|
||||
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
const trimmed = symbol.trim().toUpperCase();
|
||||
if (!trimmed) return;
|
||||
addMutation.mutate(trimmed, { onSuccess: () => setSymbol('') });
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={symbol}
|
||||
onChange={(e) => setSymbol(e.target.value)}
|
||||
placeholder="Add symbol (e.g. AAPL)"
|
||||
className="input-glass px-3 py-2 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={addMutation.isPending || !symbol.trim()}
|
||||
className="btn-gradient px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span>{addMutation.isPending ? 'Adding…' : 'Add'}</span>
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
131
frontend/src/components/watchlist/WatchlistTable.tsx
Normal file
131
frontend/src/components/watchlist/WatchlistTable.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { WatchlistEntry } from '../../lib/types';
|
||||
import { formatPrice } from '../../lib/format';
|
||||
import { Badge } from '../ui/Badge';
|
||||
import { useRemoveFromWatchlist } from '../../hooks/useWatchlist';
|
||||
|
||||
function scoreColor(score: number): string {
|
||||
if (score > 70) return 'text-emerald-400';
|
||||
if (score >= 40) return 'text-amber-400';
|
||||
return 'text-red-400';
|
||||
}
|
||||
|
||||
interface WatchlistTableProps {
|
||||
entries: WatchlistEntry[];
|
||||
}
|
||||
|
||||
export function WatchlistTable({ entries }: WatchlistTableProps) {
|
||||
const removeMutation = useRemoveFromWatchlist();
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<p className="py-8 text-center text-sm text-gray-500">
|
||||
No watchlist entries yet. Add a symbol above to get started.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06] text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="px-4 py-3">Symbol</th>
|
||||
<th className="px-4 py-3">Type</th>
|
||||
<th className="px-4 py-3">Score</th>
|
||||
<th className="px-4 py-3">Dimensions</th>
|
||||
<th className="px-4 py-3">R:R</th>
|
||||
<th className="px-4 py-3">Direction</th>
|
||||
<th className="px-4 py-3">S/R Levels</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr
|
||||
key={entry.symbol}
|
||||
className="border-b border-white/[0.04] transition-all duration-200 hover:bg-white/[0.03]"
|
||||
>
|
||||
<td className="px-4 py-3.5">
|
||||
<Link
|
||||
to={`/ticker/${entry.symbol}`}
|
||||
className="font-medium text-blue-400 hover:text-blue-300 transition-colors duration-150"
|
||||
>
|
||||
{entry.symbol}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<Badge label={entry.entry_type} variant={entry.entry_type === 'auto' ? 'auto' : 'manual'} />
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
{entry.composite_score !== null ? (
|
||||
<span className={`font-semibold ${scoreColor(entry.composite_score)}`}>
|
||||
{Math.round(entry.composite_score)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
{entry.dimensions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{entry.dimensions.map((d) => (
|
||||
<span
|
||||
key={d.dimension}
|
||||
className={`inline-block rounded-md px-1.5 py-0.5 text-xs bg-white/[0.04] ${scoreColor(d.score)}`}
|
||||
title={d.dimension}
|
||||
>
|
||||
{d.dimension.slice(0, 3).toUpperCase()} {Math.round(d.score)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3.5 font-mono">
|
||||
{entry.rr_ratio !== null ? (
|
||||
<span className="text-gray-200">{entry.rr_ratio.toFixed(2)}</span>
|
||||
) : (
|
||||
<span className="text-gray-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
{entry.rr_direction ? (
|
||||
<span className={entry.rr_direction === 'long' ? 'text-emerald-400' : entry.rr_direction === 'short' ? 'text-red-400' : 'text-gray-400'}>
|
||||
{entry.rr_direction}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
{entry.sr_levels.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{entry.sr_levels.map((level, i) => (
|
||||
<span key={i} className={`text-xs ${level.type === 'support' ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||
{formatPrice(level.price_level)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<button
|
||||
onClick={() => removeMutation.mutate(entry.symbol)}
|
||||
disabled={removeMutation.isPending}
|
||||
className="rounded-lg px-2.5 py-1 text-xs text-red-400 transition-all duration-150 hover:bg-red-500/10 hover:text-red-300 disabled:opacity-50"
|
||||
aria-label={`Remove ${entry.symbol}`}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user