feat: add frontend + backend + database to retrieve and compute news from Yahoo

This commit is contained in:
2026-04-18 23:53:57 +02:00
parent f9b6d35c49
commit 93668273ff
84 changed files with 15431 additions and 0 deletions

16
frontend/Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

15
frontend/index.html Normal file
View File

@ -0,0 +1,15 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0f172a" />
<meta name="description" content="Agrégateur de news financières avec résumés IA" />
<title>Tradarr</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

43
frontend/nginx.conf Normal file
View File

@ -0,0 +1,43 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
# Résolveur DNS Docker — résolution à la requête, pas au démarrage
resolver 127.0.0.11 valid=10s ipv6=off;
# Proxy API vers backend
location /api/ {
set $backend http://backend:8080;
proxy_pass $backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 3600s;
proxy_connect_timeout 10s;
proxy_send_timeout 3600s;
}
# Service Worker — ne pas mettre en cache
location = /sw.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
expires 0;
}
# Assets statiques avec cache long
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# SPA — renvoyer index.html pour toutes les routes React
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
}

9997
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
frontend/package.json Normal file
View File

@ -0,0 +1,44 @@
{
"name": "tradarr",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint ."
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-tooltip": "^1.1.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.475.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.1.5",
"tailwind-merge": "^2.6.0"
},
"devDependencies": {
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.19.0",
"postcss": "^8.5.2",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"vite": "^6.1.0",
"vite-plugin-pwa": "^0.21.1"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/>
<polyline points="16 7 22 7 22 13"/>
</svg>

After

Width:  |  Height:  |  Size: 252 B

52
frontend/src/api/admin.ts Normal file
View File

@ -0,0 +1,52 @@
import { api } from './client'
export interface AIProvider {
id: string; name: string; model: string; endpoint: string
is_active: boolean; has_key: boolean
}
export interface Source { id: string; name: string; type: string; enabled: boolean }
export interface ScrapeJob {
id: string; source_id: string; source_name: string; status: string
started_at: { Time: string; Valid: boolean } | null
finished_at: { Time: string; Valid: boolean } | null
articles_found: number; error_msg: string; created_at: string
}
export interface Setting { key: string; value: string }
export interface AdminUser { id: string; email: string; role: string; created_at: string }
export interface Credential { source_id: string; source_name: string; username: string; has_password: boolean }
export const adminApi = {
// Credentials
getCredentials: () => api.get<Credential[]>('/admin/credentials'),
updateCredentials: (data: { source_id: string; username: string; password?: string }) =>
api.put<void>('/admin/credentials', data),
// AI Providers
listProviders: () => api.get<AIProvider[]>('/admin/ai-providers'),
createProvider: (data: { name: string; api_key?: string; model?: string; endpoint?: string }) =>
api.post<AIProvider>('/admin/ai-providers', data),
updateProvider: (id: string, data: { name: string; api_key?: string; model?: string; endpoint?: string }) =>
api.put<void>(`/admin/ai-providers/${id}`, data),
activateProvider: (id: string) => api.post<void>(`/admin/ai-providers/${id}/activate`),
deleteProvider: (id: string) => api.delete<void>(`/admin/ai-providers/${id}`),
listModels: (id: string) => api.get<string[]>(`/admin/ai-providers/${id}/models`),
// Sources
listSources: () => api.get<Source[]>('/admin/sources'),
updateSource: (id: string, enabled: boolean) => api.put<void>(`/admin/sources/${id}`, { enabled }),
// Jobs
listJobs: () => api.get<ScrapeJob[]>('/admin/scrape-jobs'),
triggerJob: (source_id: string) => api.post<void>('/admin/scrape-jobs/trigger', { source_id }),
// Settings
listSettings: () => api.get<Setting[]>('/admin/settings'),
updateSettings: (settings: Setting[]) => api.put<void>('/admin/settings', { settings }),
getDefaultPrompt: () => api.get<{ prompt: string }>('/admin/settings/default-prompt'),
// Users
listUsers: () => api.get<AdminUser[]>('/admin/users'),
updateUser: (id: string, email: string, role: string) =>
api.put<AdminUser>(`/admin/users/${id}`, { email, role }),
deleteUser: (id: string) => api.delete<void>(`/admin/users/${id}`),
}

View File

@ -0,0 +1,25 @@
import { api } from './client'
export interface Article {
id: string
source_id: string
source_name: string
title: string
content: string
url: string
published_at: { Time: string; Valid: boolean } | null
created_at: string
symbols?: string[]
}
export const articlesApi = {
list: (params?: { symbol?: string; limit?: number; offset?: number }) => {
const q = new URLSearchParams()
if (params?.symbol) q.set('symbol', params.symbol)
if (params?.limit) q.set('limit', String(params.limit))
if (params?.offset) q.set('offset', String(params.offset))
const qs = q.toString()
return api.get<Article[]>(`/articles${qs ? `?${qs}` : ''}`)
},
get: (id: string) => api.get<Article>(`/articles/${id}`),
}

View File

@ -0,0 +1,9 @@
import { api } from './client'
export interface Asset { id: string; user_id: string; symbol: string; name: string; created_at: string }
export const assetsApi = {
list: () => api.get<Asset[]>('/me/assets'),
add: (symbol: string, name: string) => api.post<Asset>('/me/assets', { symbol, name }),
remove: (symbol: string) => api.delete<void>(`/me/assets/${symbol}`),
}

12
frontend/src/api/auth.ts Normal file
View File

@ -0,0 +1,12 @@
import { api } from './client'
export interface User { id: string; email: string; role: 'admin' | 'user' }
interface AuthResponse { token: string; user: User }
export const authApi = {
login: (email: string, password: string) =>
api.post<AuthResponse>('/auth/login', { email, password }),
register: (email: string, password: string) =>
api.post<AuthResponse>('/auth/register', { email, password }),
me: () => api.get<User>('/me'),
}

View File

@ -0,0 +1,33 @@
const BASE = '/api'
function getToken() {
return localStorage.getItem('token')
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken()
const res = await fetch(`${BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || res.statusText)
}
if (res.status === 204) return undefined as T
const json = await res.json()
return json.data !== undefined ? json.data : json
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
put: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
}

View File

@ -0,0 +1,14 @@
import { api } from './client'
export interface Summary {
id: string
user_id: string
content: string
ai_provider_id: string | null
generated_at: string
}
export const summariesApi = {
list: (limit = 10) => api.get<Summary[]>(`/summaries?limit=${limit}`),
generate: () => api.post<Summary>('/summaries/generate'),
}

View File

@ -0,0 +1,26 @@
import { Outlet, Navigate } from 'react-router-dom'
import { useAuth } from '@/lib/auth'
import { Sidebar } from './Sidebar'
import { MobileNav } from './MobileNav'
export function AppLayout() {
const { token } = useAuth()
if (!token) return <Navigate to="/login" replace />
return (
<div className="flex h-screen overflow-hidden">
{/* Sidebar desktop */}
<div className="hidden md:flex">
<Sidebar />
</div>
{/* Main content */}
<main className="flex-1 overflow-y-auto pb-16 md:pb-0">
<Outlet />
</main>
{/* Bottom nav mobile */}
<MobileNav />
</div>
)
}

View File

@ -0,0 +1,33 @@
import { NavLink } from 'react-router-dom'
import { LayoutDashboard, Newspaper, Star, Settings } from 'lucide-react'
import { cn } from '@/lib/cn'
const items = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/feed', icon: Newspaper, label: 'Actus' },
{ to: '/watchlist', icon: Star, label: 'Watchlist' },
{ to: '/admin', icon: Settings, label: 'Admin' },
]
export function MobileNav() {
return (
<nav className="fixed bottom-0 left-0 right-0 z-50 flex border-t bg-card md:hidden">
{items.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
className={({ isActive }) =>
cn(
'flex flex-1 flex-col items-center gap-1 py-3 text-xs transition-colors',
isActive ? 'text-primary' : 'text-muted-foreground'
)
}
>
<Icon className="h-5 w-5" />
{label}
</NavLink>
))}
</nav>
)
}

View File

@ -0,0 +1,85 @@
import { NavLink } from 'react-router-dom'
import { LayoutDashboard, Newspaper, Star, Settings, Key, Cpu, Database, ClipboardList, Users, LogOut, TrendingUp } from 'lucide-react'
import { useAuth } from '@/lib/auth'
import { cn } from '@/lib/cn'
const navItems = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/feed', icon: Newspaper, label: 'Actualités' },
{ to: '/watchlist', icon: Star, label: 'Watchlist' },
]
const adminItems = [
{ to: '/admin/ai', icon: Cpu, label: 'Fournisseurs IA' },
{ to: '/admin/credentials', icon: Key, label: 'Identifiants' },
{ to: '/admin/sources', icon: Database, label: 'Sources' },
{ to: '/admin/jobs', icon: ClipboardList, label: 'Jobs' },
{ to: '/admin/users', icon: Users, label: 'Utilisateurs' },
{ to: '/admin/settings', icon: Settings, label: 'Paramètres' },
]
function NavItem({ to, icon: Icon, label }: { to: string; icon: React.ElementType; label: string }) {
return (
<NavLink
to={to}
end={to === '/'}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)
}
>
<Icon className="h-4 w-4" />
{label}
</NavLink>
)
}
export function Sidebar() {
const { user, logout, isAdmin } = useAuth()
return (
<aside className="flex h-screen w-60 flex-col border-r bg-card px-3 py-4">
{/* Logo */}
<div className="mb-6 flex items-center gap-2 px-3">
<TrendingUp className="h-6 w-6 text-primary" />
<span className="text-lg font-bold">Tradarr</span>
</div>
{/* Navigation principale */}
<nav className="flex flex-col gap-1">
{navItems.map(item => <NavItem key={item.to} {...item} />)}
</nav>
{/* Section admin */}
{isAdmin && (
<>
<div className="mt-6 mb-2 px-3">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Administration</p>
</div>
<nav className="flex flex-col gap-1">
{adminItems.map(item => <NavItem key={item.to} {...item} />)}
</nav>
</>
)}
{/* User + logout */}
<div className="mt-auto border-t pt-4">
<div className="px-3 mb-2">
<p className="text-sm font-medium truncate">{user?.email}</p>
<p className="text-xs text-muted-foreground capitalize">{user?.role}</p>
</div>
<button
onClick={logout}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
>
<LogOut className="h-4 w-4" />
Déconnexion
</button>
</div>
</aside>
)
}

View File

@ -0,0 +1,26 @@
import { type HTMLAttributes } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/cn'
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground',
secondary: 'border-transparent bg-secondary text-secondary-foreground',
destructive: 'border-transparent bg-destructive text-destructive-foreground',
outline: 'text-foreground',
bullish: 'border-transparent bg-bullish/20 text-bullish',
bearish: 'border-transparent bg-bearish/20 text-bearish',
},
},
defaultVariants: { variant: 'default' },
}
)
export interface BadgeProps extends HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
export function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}

View File

@ -0,0 +1,35 @@
import { forwardRef, type ButtonHTMLAttributes } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/cn'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: { variant: 'default', size: 'default' },
}
)
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => (
<button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
)
)
Button.displayName = 'Button'

View File

@ -0,0 +1,37 @@
import { forwardRef, type HTMLAttributes } from 'react'
import { cn } from '@/lib/cn'
export const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)} {...props} />
)
)
Card.displayName = 'Card'
export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
)
)
CardHeader.displayName = 'CardHeader'
export const CardTitle = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
)
)
CardTitle.displayName = 'CardTitle'
export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
)
CardContent.displayName = 'CardContent'
export const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
)
)
CardFooter.displayName = 'CardFooter'

View File

@ -0,0 +1,17 @@
import { forwardRef, type InputHTMLAttributes } from 'react'
import { cn } from '@/lib/cn'
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {}
export const Input = forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
))
Input.displayName = 'Input'

View File

@ -0,0 +1,13 @@
import { forwardRef, type LabelHTMLAttributes } from 'react'
import { cn } from '@/lib/cn'
export const Label = forwardRef<HTMLLabelElement, LabelHTMLAttributes<HTMLLabelElement>>(
({ className, ...props }, ref) => (
<label
ref={ref}
className={cn('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', className)}
{...props}
/>
)
)
Label.displayName = 'Label'

View File

@ -0,0 +1,22 @@
import { forwardRef, type SelectHTMLAttributes } from 'react'
import { cn } from '@/lib/cn'
import { ChevronDown } from 'lucide-react'
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {}
export const Select = forwardRef<HTMLSelectElement, SelectProps>(({ className, children, ...props }, ref) => (
<div className="relative">
<select
ref={ref}
className={cn(
'flex h-9 w-full appearance-none rounded-md border border-input bg-transparent px-3 py-1 pr-8 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
{children}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
</div>
))
Select.displayName = 'Select'

View File

@ -0,0 +1,7 @@
import { cn } from '@/lib/cn'
export function Spinner({ className }: { className?: string }) {
return (
<div className={cn('h-5 w-5 animate-spin rounded-full border-2 border-current border-t-transparent', className)} />
)
}

39
frontend/src/index.css Normal file
View File

@ -0,0 +1,39 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 222 47% 6%;
--foreground: 210 40% 95%;
--card: 222 47% 9%;
--card-foreground: 210 40% 95%;
--border: 217 33% 18%;
--input: 217 33% 18%;
--ring: 221 83% 53%;
--primary: 221 83% 53%;
--primary-foreground: 210 40% 98%;
--secondary: 217 33% 18%;
--secondary-foreground: 210 40% 95%;
--muted: 217 33% 14%;
--muted-foreground: 215 20% 55%;
--accent: 217 33% 18%;
--accent-foreground: 210 40% 95%;
--destructive: 0 84% 60%;
--destructive-foreground: 210 40% 98%;
--radius: 0.5rem;
}
}
@layer base {
* { @apply border-border; }
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
}
}
/* Scrollbar style */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { @apply bg-muted; }
::-webkit-scrollbar-thumb { @apply bg-border rounded-full; }

51
frontend/src/lib/auth.tsx Normal file
View File

@ -0,0 +1,51 @@
import { createContext, useContext, useState, type ReactNode } from 'react'
interface User {
id: string
email: string
role: 'admin' | 'user'
}
interface AuthCtx {
user: User | null
token: string | null
login: (token: string, user: User) => void
logout: () => void
isAdmin: boolean
}
const AuthContext = createContext<AuthCtx | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState<string | null>(() => localStorage.getItem('token'))
const [user, setUser] = useState<User | null>(() => {
const u = localStorage.getItem('user')
return u ? JSON.parse(u) : null
})
function login(t: string, u: User) {
localStorage.setItem('token', t)
localStorage.setItem('user', JSON.stringify(u))
setToken(t)
setUser(u)
}
function logout() {
localStorage.removeItem('token')
localStorage.removeItem('user')
setToken(null)
setUser(null)
}
return (
<AuthContext.Provider value={{ user, token, login, logout, isAdmin: user?.role === 'admin' }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
return ctx
}

6
frontend/src/lib/cn.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@ -0,0 +1,38 @@
import { createBrowserRouter } from 'react-router-dom'
import { AppLayout } from '@/components/layout/AppLayout'
import { Login } from '@/pages/Login'
import { Dashboard } from '@/pages/Dashboard'
import { Feed } from '@/pages/Feed'
import { Watchlist } from '@/pages/Watchlist'
import { AdminLayout } from '@/pages/admin/AdminLayout'
import { AIProviders } from '@/pages/admin/AIProviders'
import { Credentials } from '@/pages/admin/Credentials'
import { Sources } from '@/pages/admin/Sources'
import { Jobs } from '@/pages/admin/Jobs'
import { AdminUsers } from '@/pages/admin/AdminUsers'
import { AdminSettings } from '@/pages/admin/AdminSettings'
export const router = createBrowserRouter([
{ path: '/login', element: <Login /> },
{
element: <AppLayout />,
children: [
{ path: '/', element: <Dashboard /> },
{ path: '/feed', element: <Feed /> },
{ path: '/watchlist', element: <Watchlist /> },
{
path: '/admin',
element: <AdminLayout />,
children: [
{ index: true, element: <AIProviders /> },
{ path: 'ai', element: <AIProviders /> },
{ path: 'credentials', element: <Credentials /> },
{ path: 'sources', element: <Sources /> },
{ path: 'jobs', element: <Jobs /> },
{ path: 'users', element: <AdminUsers /> },
{ path: 'settings', element: <AdminSettings /> },
],
},
],
},
])

14
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,14 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'
import { AuthProvider } from '@/lib/auth'
import { router } from '@/lib/router'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
</StrictMode>
)

View File

@ -0,0 +1,146 @@
import { useState, useEffect } from 'react'
import { TrendingUp, Clock, Sparkles } from 'lucide-react'
import { summariesApi, type Summary } from '@/api/summaries'
import { assetsApi, type Asset } from '@/api/assets'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Spinner } from '@/components/ui/spinner'
import { useAuth } from '@/lib/auth'
function SummaryContent({ content }: { content: string }) {
const lines = content.split('\n')
return (
<div className="space-y-2 text-sm leading-relaxed">
{lines.map((line, i) => {
if (line.startsWith('## ')) return <h2 key={i} className="text-base font-semibold mt-4 first:mt-0">{line.slice(3)}</h2>
if (line.startsWith('### ')) return <h3 key={i} className="font-medium mt-3">{line.slice(4)}</h3>
if (line.startsWith('- ')) return <li key={i} className="ml-4 text-muted-foreground">{line.slice(2)}</li>
if (line.startsWith('**') && line.endsWith('**')) return <p key={i} className="font-semibold">{line.slice(2, -2)}</p>
if (line.trim() === '') return <div key={i} className="h-1" />
return <p key={i} className="text-muted-foreground">{line}</p>
})}
</div>
)
}
export function Dashboard() {
const { user } = useAuth()
const [summaries, setSummaries] = useState<Summary[]>([])
const [assets, setAssets] = useState<Asset[]>([])
const [loading, setLoading] = useState(true)
const [generating, setGenerating] = useState(false)
const [current, setCurrent] = useState<Summary | null>(null)
useEffect(() => { load() }, [])
async function load() {
setLoading(true)
try {
const [s, a] = await Promise.all([summariesApi.list(5), assetsApi.list()])
setSummaries(s ?? [])
setAssets(a ?? [])
setCurrent(s?.[0] ?? null)
} finally { setLoading(false) }
}
async function generate() {
setGenerating(true)
try {
const s = await summariesApi.generate()
setSummaries(prev => [s, ...prev])
setCurrent(s)
} catch (e) {
alert(e instanceof Error ? e.message : 'Erreur lors de la génération')
} finally { setGenerating(false) }
}
return (
<div className="p-4 md:p-6 space-y-6">
{/* Header */}
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">Bonjour 👋</h1>
<p className="text-muted-foreground text-sm">{user?.email}</p>
</div>
<Button onClick={generate} disabled={generating || assets.length === 0}>
{generating ? <><Spinner className="h-4 w-4" /> Génération</> : <><Sparkles className="h-4 w-4" /> Générer un résumé</>}
</Button>
</div>
{assets.length === 0 && !loading && (
<Card className="border-dashed">
<CardContent className="py-8 text-center">
<TrendingUp className="h-10 w-10 mx-auto text-muted-foreground mb-3" />
<p className="font-medium">Votre watchlist est vide</p>
<p className="text-sm text-muted-foreground mt-1">Ajoutez des symboles dans la section Watchlist pour obtenir des résumés personnalisés</p>
</CardContent>
</Card>
)}
{assets.length > 0 && (
<div className="flex flex-wrap gap-2">
{assets.map(a => (
<Badge key={a.id} variant="secondary" className="font-mono">{a.symbol}</Badge>
))}
</div>
)}
{/* Résumé actuel */}
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2 space-y-4">
{loading ? (
<div className="flex justify-center py-20"><Spinner /></div>
) : current ? (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" /> Résumé IA
</CardTitle>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
{new Date(current.generated_at).toLocaleString('fr-FR')}
</div>
</div>
</CardHeader>
<CardContent>
<SummaryContent content={current.content} />
</CardContent>
</Card>
) : (
<Card className="border-dashed">
<CardContent className="py-12 text-center text-muted-foreground">
<Sparkles className="h-8 w-8 mx-auto mb-3 opacity-50" />
<p>Aucun résumé disponible</p>
<p className="text-xs mt-1">Cliquez sur "Générer un résumé" pour commencer</p>
</CardContent>
</Card>
)}
</div>
{/* Historique résumés */}
{summaries.length > 1 && (
<div className="space-y-3">
<h2 className="font-semibold text-sm text-muted-foreground uppercase tracking-wider">Historique</h2>
{summaries.slice(1).map(s => (
<Card
key={s.id}
className={`cursor-pointer transition-colors hover:border-primary/50 ${current?.id === s.id ? 'border-primary/50' : ''}`}
onClick={() => setCurrent(s)}
>
<CardContent className="py-3">
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
<Clock className="h-3 w-3" />
{new Date(s.generated_at).toLocaleString('fr-FR')}
</div>
<p className="text-sm line-clamp-3">{s.content.slice(0, 120)}</p>
</CardContent>
</Card>
))}
</div>
)}
</div>
</div>
)
}

116
frontend/src/pages/Feed.tsx Normal file
View File

@ -0,0 +1,116 @@
import { useState, useEffect } from 'react'
import { ExternalLink, RefreshCw, Search } from 'lucide-react'
import { articlesApi, type Article } from '@/api/articles'
import { assetsApi, type Asset } from '@/api/assets'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Spinner } from '@/components/ui/spinner'
function fmtDate(a: Article) {
const d = a.published_at?.Valid ? new Date(a.published_at.Time) : new Date(a.created_at)
const now = Date.now()
const diff = now - d.getTime()
if (diff < 3600000) return `Il y a ${Math.floor(diff / 60000)} min`
if (diff < 86400000) return `Il y a ${Math.floor(diff / 3600000)} h`
return d.toLocaleDateString('fr-FR')
}
export function Feed() {
const [articles, setArticles] = useState<Article[]>([])
const [assets, setAssets] = useState<Asset[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [filterSymbol, setFilterSymbol] = useState('')
const [offset, setOffset] = useState(0)
const limit = 30
useEffect(() => {
assetsApi.list().then(a => setAssets(a ?? []))
}, [])
useEffect(() => { load(0) }, [filterSymbol])
async function load(newOffset = 0) {
setLoading(true)
try {
const data = await articlesApi.list({ symbol: filterSymbol || undefined, limit, offset: newOffset })
if (newOffset === 0) setArticles(data ?? [])
else setArticles(prev => [...prev, ...(data ?? [])])
setOffset(newOffset + limit)
} finally { setLoading(false) }
}
const filtered = search
? articles.filter(a => a.title.toLowerCase().includes(search.toLowerCase()) || a.source_name?.toLowerCase().includes(search.toLowerCase()))
: articles
return (
<div className="p-4 md:p-6 space-y-4">
<div className="flex flex-wrap items-center gap-4">
<h1 className="text-2xl font-bold flex-1">Actualités</h1>
<Button variant="outline" size="icon" onClick={() => load(0)}><RefreshCw className="h-4 w-4" /></Button>
</div>
{/* Filtres */}
<div className="flex flex-wrap gap-3">
<div className="relative flex-1 min-w-48">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input className="pl-8" placeholder="Rechercher…" value={search} onChange={e => setSearch(e.target.value)} />
</div>
<Select value={filterSymbol} onChange={e => setFilterSymbol(e.target.value)} className="w-40">
<option value="">Tous les symboles</option>
{assets.map(a => <option key={a.id} value={a.symbol}>{a.symbol}</option>)}
</Select>
</div>
{loading && articles.length === 0 ? (
<div className="flex justify-center py-20"><Spinner /></div>
) : (
<>
<div className="space-y-3">
{filtered.map(a => (
<Card key={a.id} className="hover:border-border/80 transition-colors">
<CardContent className="py-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
<Badge variant="outline" className="text-xs">{a.source_name}</Badge>
<span className="text-xs text-muted-foreground">{fmtDate(a)}</span>
</div>
<h3 className="font-medium leading-snug mb-2">{a.title}</h3>
{a.content && (
<p className="text-sm text-muted-foreground line-clamp-2">{a.content}</p>
)}
</div>
<a
href={a.url}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink className="h-4 w-4" />
</a>
</div>
</CardContent>
</Card>
))}
{filtered.length === 0 && (
<Card><CardContent className="py-12 text-center text-muted-foreground">Aucun article</CardContent></Card>
)}
</div>
{filtered.length >= limit && (
<div className="flex justify-center pt-2">
<Button variant="outline" onClick={() => load(offset)} disabled={loading}>
{loading ? <Spinner className="h-4 w-4" /> : 'Charger plus'}
</Button>
</div>
)}
</>
)}
</div>
)
}

View File

@ -0,0 +1,63 @@
import { useState, type FormEvent } from 'react'
import { Navigate } from 'react-router-dom'
import { TrendingUp } from 'lucide-react'
import { useAuth } from '@/lib/auth'
import { authApi } from '@/api/auth'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
export function Login() {
const { token, login } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
if (token) return <Navigate to="/" replace />
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
try {
const { token: t, user } = await authApi.login(email, password)
login(t, user)
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur de connexion')
} finally {
setLoading(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<div className="mb-2 flex justify-center">
<TrendingUp className="h-10 w-10 text-primary" />
</div>
<CardTitle className="text-2xl">Tradarr</CardTitle>
<p className="text-sm text-muted-foreground">Votre assistant trading IA</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoComplete="email" />
</div>
<div className="space-y-1">
<Label htmlFor="password">Mot de passe</Label>
<Input id="password" type="password" value={password} onChange={e => setPassword(e.target.value)} required autoComplete="current-password" />
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Connexion…' : 'Se connecter'}
</Button>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,117 @@
import { useState, useEffect, type FormEvent } from 'react'
import { Plus, Trash2, TrendingUp } from 'lucide-react'
import { assetsApi, type Asset } from '@/api/assets'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Spinner } from '@/components/ui/spinner'
export function Watchlist() {
const [assets, setAssets] = useState<Asset[]>([])
const [loading, setLoading] = useState(true)
const [symbol, setSymbol] = useState('')
const [name, setName] = useState('')
const [adding, setAdding] = useState(false)
const [removing, setRemoving] = useState<string | null>(null)
const [error, setError] = useState('')
useEffect(() => { load() }, [])
async function load() {
setLoading(true)
try { setAssets(await assetsApi.list() ?? []) } finally { setLoading(false) }
}
async function add(e: FormEvent) {
e.preventDefault()
if (!symbol) return
setAdding(true); setError('')
try {
await assetsApi.add(symbol.toUpperCase(), name)
setSymbol(''); setName('')
await load()
} catch (err) { setError(err instanceof Error ? err.message : 'Erreur') } finally { setAdding(false) }
}
async function remove(sym: string) {
setRemoving(sym)
await assetsApi.remove(sym)
await load()
setRemoving(null)
}
return (
<div className="p-4 md:p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold">Watchlist</h1>
<p className="text-muted-foreground text-sm">Les symboles suivis seront utilisés pour personnaliser vos résumés IA</p>
</div>
{/* Formulaire d'ajout */}
<Card>
<CardHeader><CardTitle className="text-base">Ajouter un actif</CardTitle></CardHeader>
<CardContent>
<form onSubmit={add} className="flex flex-wrap gap-3 items-end">
<div className="space-y-1 flex-1 min-w-32">
<Label>Symbole</Label>
<Input
placeholder="AAPL, TSLA, BTC…"
value={symbol}
onChange={e => setSymbol(e.target.value.toUpperCase())}
className="font-mono"
required
/>
</div>
<div className="space-y-1 flex-1 min-w-40">
<Label>Nom <span className="text-muted-foreground">(optionnel)</span></Label>
<Input
placeholder="Apple Inc."
value={name}
onChange={e => setName(e.target.value)}
/>
</div>
<Button type="submit" disabled={adding || !symbol}>
{adding ? <Spinner className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
Ajouter
</Button>
</form>
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
</CardContent>
</Card>
{/* Liste */}
{loading ? (
<div className="flex justify-center py-12"><Spinner /></div>
) : assets.length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-12 text-center">
<TrendingUp className="h-10 w-10 mx-auto text-muted-foreground mb-3 opacity-50" />
<p className="text-muted-foreground">Aucun actif dans votre watchlist</p>
</CardContent>
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{assets.map(a => (
<Card key={a.id} className="flex items-center justify-between p-4">
<div className="flex items-center gap-3 min-w-0">
<Badge variant="secondary" className="font-mono shrink-0">{a.symbol}</Badge>
{a.name && <span className="text-sm text-muted-foreground truncate">{a.name}</span>}
</div>
<Button
variant="ghost"
size="icon"
className="shrink-0 h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() => remove(a.symbol)}
disabled={removing === a.symbol}
>
{removing === a.symbol ? <Spinner className="h-3 w-3" /> : <Trash2 className="h-3 w-3" />}
</Button>
</Card>
))}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,192 @@
import { useState, useEffect } from 'react'
import { Plus, Trash2, CheckCircle, RefreshCw } from 'lucide-react'
import { adminApi, type AIProvider } from '@/api/admin'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Spinner } from '@/components/ui/spinner'
const PROVIDER_NAMES = ['openai', 'anthropic', 'gemini', 'ollama'] as const
export function AIProviders() {
const [providers, setProviders] = useState<AIProvider[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState({ name: 'openai', api_key: '', model: '', endpoint: '' })
const isOllama = form.name === 'ollama'
const [models, setModels] = useState<Record<string, string[]>>({})
const [loadingModels, setLoadingModels] = useState<string | null>(null)
const [editId, setEditId] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => { load() }, [])
async function load() {
setLoading(true)
try { setProviders((await adminApi.listProviders()) ?? []) } finally { setLoading(false) }
}
async function loadModels(id: string) {
setLoadingModels(id)
try {
const m = await adminApi.listModels(id)
setModels(prev => ({ ...prev, [id]: m }))
} catch { /* silently ignore */ } finally { setLoadingModels(null) }
}
async function save() {
setSaving(true); setError('')
try {
if (editId) {
await adminApi.updateProvider(editId, form)
} else {
await adminApi.createProvider(form)
}
setShowForm(false); setEditId(null)
setForm({ name: 'openai', api_key: '', model: '', endpoint: '' })
await load()
} catch (e) { setError(e instanceof Error ? e.message : 'Erreur') } finally { setSaving(false) }
}
async function activate(id: string) {
await adminApi.activateProvider(id)
await load()
}
async function remove(id: string) {
if (!confirm('Supprimer ce fournisseur ?')) return
await adminApi.deleteProvider(id)
await load()
}
function startEdit(p: AIProvider) {
setEditId(p.id)
setForm({ name: p.name, api_key: '', model: p.model, endpoint: p.endpoint })
setShowForm(true)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Fournisseurs IA</h1>
<p className="text-muted-foreground text-sm">Configurez les fournisseurs IA et sélectionnez le modèle actif</p>
</div>
<Button onClick={() => { setShowForm(true); setEditId(null) }}>
<Plus className="h-4 w-4" /> Ajouter
</Button>
</div>
{showForm && (
<Card>
<CardHeader><CardTitle>{editId ? 'Modifier' : 'Nouveau fournisseur'}</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<Label>Fournisseur</Label>
<Select
value={form.name}
onChange={e => {
const name = e.target.value
setForm(f => ({
...f,
name,
endpoint: name === 'ollama' ? 'http://ollama:11434' : '',
api_key: '',
}))
}}
disabled={!!editId}
>
{PROVIDER_NAMES.map(n => <option key={n} value={n}>{n}</option>)}
</Select>
</div>
{!isOllama && (
<div className="space-y-1">
<Label>Clé API {editId && <span className="text-muted-foreground">(laisser vide pour conserver)</span>}</Label>
<Input type="password" placeholder="sk-..." value={form.api_key} onChange={e => setForm(f => ({ ...f, api_key: e.target.value }))} />
</div>
)}
<div className="space-y-1">
<Label>Modèle</Label>
<Input placeholder="gpt-4o-mini, claude-sonnet-4-6…" value={form.model} onChange={e => setForm(f => ({ ...f, model: e.target.value }))} />
</div>
{isOllama && (
<div className="space-y-1">
<Label>Endpoint Ollama</Label>
<Input value="http://ollama:11434" readOnly className="opacity-60 cursor-not-allowed" />
</div>
)}
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex gap-2">
<Button onClick={save} disabled={saving}>{saving ? <Spinner className="h-4 w-4" /> : 'Enregistrer'}</Button>
<Button variant="outline" onClick={() => { setShowForm(false); setEditId(null) }}>Annuler</Button>
</div>
</CardContent>
</Card>
)}
{loading ? (
<div className="flex justify-center py-12"><Spinner /></div>
) : (
<div className="space-y-3">
{providers.map(p => (
<Card key={p.id} className={p.is_active ? 'border-primary/50' : ''}>
<CardContent className="flex flex-wrap items-center gap-4 py-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold capitalize">{p.name}</span>
{p.is_active && <Badge variant="default">Actif</Badge>}
{!p.has_key && p.name !== 'ollama' && <Badge variant="outline" className="text-yellow-500 border-yellow-500">Sans clé</Badge>}
</div>
<div className="text-sm text-muted-foreground mt-1 flex gap-4 flex-wrap">
{p.model && <span>Modèle : <strong>{p.model}</strong></span>}
{p.endpoint && <span>Endpoint : {p.endpoint}</span>}
</div>
{/* Dropdown modèles disponibles */}
{models[p.id] && models[p.id].length > 0 && (
<div className="mt-2 space-y-1">
<Label className="text-xs">Choisir un modèle :</Label>
<Select
className="w-full max-w-xs"
value={p.model}
onChange={async e => {
await adminApi.updateProvider(p.id, { name: p.name, model: e.target.value, endpoint: p.endpoint })
await load()
}}
>
{models[p.id].map(m => <option key={m} value={m}>{m}</option>)}
</Select>
</div>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
<Button variant="outline" size="sm" onClick={() => loadModels(p.id)} disabled={loadingModels === p.id}>
{loadingModels === p.id ? <Spinner className="h-3 w-3" /> : <RefreshCw className="h-3 w-3" />}
Modèles
</Button>
<Button variant="outline" size="sm" onClick={() => startEdit(p)}>Modifier</Button>
{!p.is_active && (
<Button size="sm" onClick={() => activate(p.id)}>
<CheckCircle className="h-3 w-3" /> Activer
</Button>
)}
<Button variant="destructive" size="sm" onClick={() => remove(p.id)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</CardContent>
</Card>
))}
{providers.length === 0 && (
<Card><CardContent className="py-8 text-center text-muted-foreground">Aucun fournisseur configuré</CardContent></Card>
)}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,12 @@
import { Outlet, Navigate } from 'react-router-dom'
import { useAuth } from '@/lib/auth'
export function AdminLayout() {
const { isAdmin } = useAuth()
if (!isAdmin) return <Navigate to="/" replace />
return (
<div className="p-6">
<Outlet />
</div>
)
}

View File

@ -0,0 +1,131 @@
import { useState, useEffect } from 'react'
import { Save, RotateCcw } from 'lucide-react'
import { adminApi, type Setting } from '@/api/admin'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Spinner } from '@/components/ui/spinner'
const NUMERIC_SETTINGS: Record<string, { label: string; description: string }> = {
scrape_interval_minutes: { label: 'Intervalle de scraping (minutes)', description: 'Fréquence de récupération des actualités' },
articles_lookback_hours: { label: 'Fenêtre d\'analyse (heures)', description: 'Période couverte pour les résumés IA' },
summary_max_articles: { label: 'Articles max par résumé', description: 'Nombre maximum d\'articles envoyés à l\'IA' },
}
export function AdminSettings() {
const [settings, setSettings] = useState<Setting[]>([])
const [loading, setLoading] = useState(true)
const [values, setValues] = useState<Record<string, string>>({})
const [defaultPrompt, setDefaultPrompt] = useState('')
const [saving, setSaving] = useState(false)
const [savingPrompt, setSavingPrompt] = useState(false)
const [saved, setSaved] = useState(false)
const [savedPrompt, setSavedPrompt] = useState(false)
useEffect(() => { load() }, [])
async function load() {
setLoading(true)
try {
const [s, d] = await Promise.all([
adminApi.listSettings(),
adminApi.getDefaultPrompt(),
])
const settled = s ?? []
setSettings(settled)
setDefaultPrompt(d?.prompt ?? '')
const v: Record<string, string> = {}
for (const setting of settled) v[setting.key] = setting.value
setValues(v)
} finally { setLoading(false) }
}
async function saveNumeric() {
setSaving(true); setSaved(false)
const numericKeys = Object.keys(NUMERIC_SETTINGS)
await adminApi.updateSettings(
settings
.filter(s => numericKeys.includes(s.key))
.map(s => ({ key: s.key, value: values[s.key] ?? s.value }))
)
setSaving(false); setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
async function savePrompt() {
setSavingPrompt(true); setSavedPrompt(false)
await adminApi.updateSettings([{ key: 'ai_system_prompt', value: values['ai_system_prompt'] ?? '' }])
setSavingPrompt(false); setSavedPrompt(true)
setTimeout(() => setSavedPrompt(false), 2000)
}
function resetPrompt() {
setValues(v => ({ ...v, ai_system_prompt: defaultPrompt }))
}
if (loading) return <div className="flex justify-center py-20"><Spinner /></div>
const currentPrompt = values['ai_system_prompt'] ?? defaultPrompt
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Paramètres</h1>
<p className="text-muted-foreground text-sm">Configuration globale du service</p>
</div>
<Card>
<CardHeader><CardTitle>Paramètres généraux</CardTitle></CardHeader>
<CardContent className="space-y-6">
{settings.filter(s => NUMERIC_SETTINGS[s.key]).map(s => {
const meta = NUMERIC_SETTINGS[s.key]
return (
<div key={s.key} className="space-y-1">
<Label>{meta.label}</Label>
<p className="text-xs text-muted-foreground">{meta.description}</p>
<Input
value={values[s.key] ?? ''}
onChange={e => setValues(v => ({ ...v, [s.key]: e.target.value }))}
className="max-w-xs"
type="number"
min="1"
/>
</div>
)
})}
<Button onClick={saveNumeric} disabled={saving}>
{saving ? <Spinner className="h-4 w-4" /> : <Save className="h-4 w-4" />}
{saved ? 'Enregistré !' : 'Enregistrer'}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Contexte IA</CardTitle>
<p className="text-sm text-muted-foreground">
Instructions envoyées à l'IA avant les articles. Les actifs de ta watchlist et les articles sont ajoutés automatiquement après ce texte.
</p>
</CardHeader>
<CardContent className="space-y-4">
<textarea
className="w-full min-h-[280px] rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-y focus:outline-none focus:ring-2 focus:ring-ring"
value={currentPrompt}
onChange={e => setValues(v => ({ ...v, ai_system_prompt: e.target.value }))}
spellCheck={false}
/>
<div className="flex gap-2">
<Button onClick={savePrompt} disabled={savingPrompt}>
{savingPrompt ? <Spinner className="h-4 w-4" /> : <Save className="h-4 w-4" />}
{savedPrompt ? 'Enregistré !' : 'Enregistrer le contexte'}
</Button>
<Button variant="outline" onClick={resetPrompt} title="Remettre le contexte par défaut">
<RotateCcw className="h-4 w-4" /> Réinitialiser
</Button>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,113 @@
import { useState, useEffect } from 'react'
import { Trash2, Pencil } from 'lucide-react'
import { adminApi, type AdminUser } from '@/api/admin'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Spinner } from '@/components/ui/spinner'
import { useAuth } from '@/lib/auth'
export function AdminUsers() {
const { user: me } = useAuth()
const [users, setUsers] = useState<AdminUser[]>([])
const [loading, setLoading] = useState(true)
const [editId, setEditId] = useState<string | null>(null)
const [editForm, setEditForm] = useState({ email: '', role: 'user' })
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => { load() }, [])
async function load() {
setLoading(true)
try { setUsers((await adminApi.listUsers()) ?? []) } finally { setLoading(false) }
}
function startEdit(u: AdminUser) {
setEditId(u.id); setEditForm({ email: u.email, role: u.role }); setError('')
}
async function save() {
if (!editId) return
setSaving(true); setError('')
try {
await adminApi.updateUser(editId, editForm.email, editForm.role)
setEditId(null); await load()
} catch (e) { setError(e instanceof Error ? e.message : 'Erreur') } finally { setSaving(false) }
}
async function remove(id: string) {
if (!confirm('Supprimer cet utilisateur ?')) return
await adminApi.deleteUser(id)
await load()
}
if (loading) return <div className="flex justify-center py-20"><Spinner /></div>
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Utilisateurs</h1>
<p className="text-muted-foreground text-sm">{users.length} utilisateur(s)</p>
</div>
<Card>
<CardHeader><CardTitle>Liste</CardTitle></CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b">
<tr className="text-left text-muted-foreground">
<th className="px-4 py-3">Email</th>
<th className="px-4 py-3">Rôle</th>
<th className="px-4 py-3">Inscrit le</th>
<th className="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3">
{editId === u.id ? (
<Input value={editForm.email} onChange={e => setEditForm(f => ({ ...f, email: e.target.value }))} className="h-7" />
) : (
<span className={u.id === me?.id ? 'font-medium' : ''}>{u.email}</span>
)}
</td>
<td className="px-4 py-3">
{editId === u.id ? (
<Select value={editForm.role} onChange={e => setEditForm(f => ({ ...f, role: e.target.value }))} className="h-7 w-28">
<option value="user">user</option>
<option value="admin">admin</option>
</Select>
) : (
<Badge variant={u.role === 'admin' ? 'default' : 'secondary'}>{u.role}</Badge>
)}
</td>
<td className="px-4 py-3 text-muted-foreground">{new Date(u.created_at).toLocaleDateString('fr-FR')}</td>
<td className="px-4 py-3">
{editId === u.id ? (
<div className="flex gap-2">
<Button size="sm" onClick={save} disabled={saving}>{saving ? <Spinner className="h-3 w-3" /> : 'OK'}</Button>
<Button size="sm" variant="outline" onClick={() => setEditId(null)}>Annuler</Button>
{error && <span className="text-destructive text-xs">{error}</span>}
</div>
) : (
<div className="flex gap-2">
<Button variant="outline" size="icon" className="h-7 w-7" onClick={() => startEdit(u)}><Pencil className="h-3 w-3" /></Button>
{u.id !== me?.id && (
<Button variant="destructive" size="icon" className="h-7 w-7" onClick={() => remove(u.id)}><Trash2 className="h-3 w-3" /></Button>
)}
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,114 @@
import { useState, useEffect } from 'react'
import { Eye, EyeOff, Save } from 'lucide-react'
import { adminApi, type Credential } from '@/api/admin'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Spinner } from '@/components/ui/spinner'
export function Credentials() {
const [creds, setCreds] = useState<Credential[]>([])
const [loading, setLoading] = useState(true)
const [forms, setForms] = useState<Record<string, { username: string; password: string; show: boolean }>>({})
const [saving, setSaving] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
useEffect(() => { load() }, [])
async function load() {
setLoading(true)
try {
const data = await adminApi.getCredentials()
setCreds(data ?? [])
const init: typeof forms = {}
for (const c of data ?? []) {
init[c.source_id] = { username: c.username, password: '', show: false }
}
setForms(init)
} finally { setLoading(false) }
}
function update(sourceId: string, field: string, value: string | boolean) {
setForms(f => ({ ...f, [sourceId]: { ...f[sourceId], [field]: value } }))
}
async function save(sourceId: string) {
setSaving(sourceId); setSuccess(null)
const f = forms[sourceId]
await adminApi.updateCredentials({
source_id: sourceId,
username: f.username,
...(f.password ? { password: f.password } : {}),
})
setSaving(null)
setSuccess(sourceId)
setTimeout(() => setSuccess(null), 2000)
await load()
}
if (loading) return <div className="flex justify-center py-20"><Spinner /></div>
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Identifiants de scraping</h1>
<p className="text-muted-foreground text-sm">Les mots de passe sont chiffrés en AES-256-GCM côté serveur</p>
</div>
{creds.length === 0 && (
<Card><CardContent className="py-8 text-center text-muted-foreground">Aucune source nécessitant des identifiants</CardContent></Card>
)}
{creds.map(cred => {
const f = forms[cred.source_id] ?? { username: '', password: '', show: false }
return (
<Card key={cred.source_id}>
<CardHeader>
<CardTitle className="flex items-center gap-2">{cred.source_name}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<Label htmlFor={`user-${cred.source_id}`}>Identifiant / Email</Label>
<Input
id={`user-${cred.source_id}`}
value={f.username}
onChange={e => update(cred.source_id, 'username', e.target.value)}
placeholder="votre@email.com"
/>
</div>
<div className="space-y-1">
<Label htmlFor={`pwd-${cred.source_id}`}>
Mot de passe {cred.has_password && <span className="text-muted-foreground">(déjà défini laisser vide pour ne pas changer)</span>}
</Label>
<div className="relative">
<Input
id={`pwd-${cred.source_id}`}
type={f.show ? 'text' : 'password'}
value={f.password}
onChange={e => update(cred.source_id, 'password', e.target.value)}
placeholder={cred.has_password ? '••••••••' : 'Nouveau mot de passe'}
className="pr-10"
/>
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => update(cred.source_id, 'show', !f.show)}
>
{f.show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
</div>
<Button onClick={() => save(cred.source_id)} disabled={saving === cred.source_id}>
{saving === cred.source_id ? <Spinner className="h-4 w-4" /> : <Save className="h-4 w-4" />}
{success === cred.source_id ? 'Enregistré !' : 'Enregistrer'}
</Button>
</CardContent>
</Card>
)
})}
</div>
)
}

View File

@ -0,0 +1,109 @@
import { useState, useEffect } from 'react'
import { Play, RefreshCw } from 'lucide-react'
import { adminApi, type ScrapeJob, type Source } from '@/api/admin'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Select } from '@/components/ui/select'
import { Spinner } from '@/components/ui/spinner'
function jobStatusVariant(status: string) {
if (status === 'done') return 'bullish'
if (status === 'error') return 'bearish'
if (status === 'running') return 'default'
return 'outline'
}
function fmtDate(t: { Time: string; Valid: boolean } | null) {
if (!t?.Valid) return '—'
return new Date(t.Time).toLocaleString('fr-FR')
}
export function Jobs() {
const [jobs, setJobs] = useState<ScrapeJob[]>([])
const [sources, setSources] = useState<Source[]>([])
const [loading, setLoading] = useState(true)
const [triggering, setTriggering] = useState(false)
const [selectedSource, setSelectedSource] = useState('')
useEffect(() => { load() }, [])
async function load() {
setLoading(true)
try {
const [j, s] = await Promise.all([adminApi.listJobs(), adminApi.listSources()])
setJobs(j ?? [])
setSources(s ?? [])
if (s?.[0]) setSelectedSource(s[0].id)
} finally { setLoading(false) }
}
async function trigger() {
if (!selectedSource) return
setTriggering(true)
await adminApi.triggerJob(selectedSource)
setTimeout(() => { load(); setTriggering(false) }, 1000)
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">Jobs de scraping</h1>
<p className="text-muted-foreground text-sm">Historique et déclenchement manuel</p>
</div>
<div className="flex items-center gap-2">
<Select value={selectedSource} onChange={e => setSelectedSource(e.target.value)} className="w-40">
{sources.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</Select>
<Button onClick={trigger} disabled={triggering || !selectedSource}>
{triggering ? <Spinner className="h-4 w-4" /> : <Play className="h-4 w-4" />}
Lancer
</Button>
<Button variant="outline" size="icon" onClick={load}><RefreshCw className="h-4 w-4" /></Button>
</div>
</div>
{loading ? (
<div className="flex justify-center py-12"><Spinner /></div>
) : (
<Card>
<CardHeader><CardTitle>Historique</CardTitle></CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b">
<tr className="text-left text-muted-foreground">
<th className="px-4 py-3">Source</th>
<th className="px-4 py-3">Statut</th>
<th className="px-4 py-3">Début</th>
<th className="px-4 py-3">Fin</th>
<th className="px-4 py-3">Articles</th>
<th className="px-4 py-3">Erreur</th>
</tr>
</thead>
<tbody>
{jobs.map(j => (
<tr key={j.id} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 font-medium">{j.source_name}</td>
<td className="px-4 py-3">
<Badge variant={jobStatusVariant(j.status) as 'bullish' | 'bearish' | 'default' | 'outline'}>{j.status}</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground">{fmtDate(j.started_at)}</td>
<td className="px-4 py-3 text-muted-foreground">{fmtDate(j.finished_at)}</td>
<td className="px-4 py-3">{j.articles_found}</td>
<td className="px-4 py-3 text-destructive max-w-xs truncate">{j.error_msg || '—'}</td>
</tr>
))}
{jobs.length === 0 && (
<tr><td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">Aucun job</td></tr>
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@ -0,0 +1,56 @@
import { useState, useEffect } from 'react'
import { adminApi, type Source } from '@/api/admin'
import { Card, CardContent } from '@/components/ui/card'
import { Spinner } from '@/components/ui/spinner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
export function Sources() {
const [sources, setSources] = useState<Source[]>([])
const [loading, setLoading] = useState(true)
const [toggling, setToggling] = useState<string | null>(null)
useEffect(() => { load() }, [])
async function load() {
setLoading(true)
try { setSources((await adminApi.listSources()) ?? []) } finally { setLoading(false) }
}
async function toggle(s: Source) {
setToggling(s.id)
await adminApi.updateSource(s.id, !s.enabled)
await load()
setToggling(null)
}
if (loading) return <div className="flex justify-center py-20"><Spinner /></div>
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Sources de news</h1>
<p className="text-muted-foreground text-sm">Activez ou désactivez les sources de données</p>
</div>
<div className="space-y-3">
{sources.map(s => (
<Card key={s.id}>
<CardContent className="flex items-center justify-between py-4">
<div>
<span className="font-semibold">{s.name}</span>
<span className="ml-2 text-xs text-muted-foreground capitalize">({s.type})</span>
</div>
<div className="flex items-center gap-3">
<Badge variant={s.enabled ? 'default' : 'outline'}>
{s.enabled ? 'Activée' : 'Désactivée'}
</Badge>
<Button variant="outline" size="sm" onClick={() => toggle(s)} disabled={toggling === s.id}>
{toggling === s.id ? <Spinner className="h-3 w-3" /> : s.enabled ? 'Désactiver' : 'Activer'}
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,32 @@
import type { Config } from 'tailwindcss'
export default {
darkMode: ['class'],
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: { DEFAULT: 'hsl(var(--primary))', foreground: 'hsl(var(--primary-foreground))' },
secondary: { DEFAULT: 'hsl(var(--secondary))', foreground: 'hsl(var(--secondary-foreground))' },
destructive: { DEFAULT: 'hsl(var(--destructive))', foreground: 'hsl(var(--destructive-foreground))' },
muted: { DEFAULT: 'hsl(var(--muted))', foreground: 'hsl(var(--muted-foreground))' },
accent: { DEFAULT: 'hsl(var(--accent))', foreground: 'hsl(var(--accent-foreground))' },
card: { DEFAULT: 'hsl(var(--card))', foreground: 'hsl(var(--card-foreground))' },
bullish: '#22c55e',
bearish: '#ef4444',
neutral: '#94a3b8',
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [],
} satisfies Config

22
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src"]
}

View File

@ -0,0 +1 @@
{"root":["./src/main.tsx","./src/api/admin.ts","./src/api/articles.ts","./src/api/assets.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/summaries.ts","./src/components/layout/AppLayout.tsx","./src/components/layout/MobileNav.tsx","./src/components/layout/Sidebar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/spinner.tsx","./src/lib/auth.tsx","./src/lib/cn.ts","./src/lib/router.tsx","./src/pages/Dashboard.tsx","./src/pages/Feed.tsx","./src/pages/Login.tsx","./src/pages/Watchlist.tsx","./src/pages/admin/AIProviders.tsx","./src/pages/admin/AdminLayout.tsx","./src/pages/admin/AdminSettings.tsx","./src/pages/admin/AdminUsers.tsx","./src/pages/admin/Credentials.tsx","./src/pages/admin/Jobs.tsx","./src/pages/admin/Sources.tsx"],"version":"5.9.3"}

44
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,44 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
import path from 'path'
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.svg', 'icons/*.png'],
manifest: {
name: 'Tradarr',
short_name: 'Tradarr',
description: 'Agrégateur de news financières avec résumés IA',
theme_color: '#0f172a',
background_color: '#0f172a',
display: 'standalone',
icons: [
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
runtimeCaching: [
{
urlPattern: /^\/api\//,
handler: 'NetworkFirst',
options: { cacheName: 'api-cache', expiration: { maxEntries: 50, maxAgeSeconds: 300 } },
},
],
},
}),
],
resolve: {
alias: { '@': path.resolve(__dirname, 'src') },
},
server: {
proxy: {
'/api': { target: 'http://localhost:8080', changeOrigin: true },
},
},
})