feat: add frontend + backend + database to retrieve and compute news from Yahoo
This commit is contained in:
16
frontend/Dockerfile
Normal file
16
frontend/Dockerfile
Normal 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
15
frontend/index.html
Normal 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
43
frontend/nginx.conf
Normal 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
9997
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
frontend/package.json
Normal file
44
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
4
frontend/public/favicon.svg
Normal file
4
frontend/public/favicon.svg
Normal 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
52
frontend/src/api/admin.ts
Normal 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}`),
|
||||
}
|
||||
25
frontend/src/api/articles.ts
Normal file
25
frontend/src/api/articles.ts
Normal 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}`),
|
||||
}
|
||||
9
frontend/src/api/assets.ts
Normal file
9
frontend/src/api/assets.ts
Normal 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
12
frontend/src/api/auth.ts
Normal 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'),
|
||||
}
|
||||
33
frontend/src/api/client.ts
Normal file
33
frontend/src/api/client.ts
Normal 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' }),
|
||||
}
|
||||
14
frontend/src/api/summaries.ts
Normal file
14
frontend/src/api/summaries.ts
Normal 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'),
|
||||
}
|
||||
26
frontend/src/components/layout/AppLayout.tsx
Normal file
26
frontend/src/components/layout/AppLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
frontend/src/components/layout/MobileNav.tsx
Normal file
33
frontend/src/components/layout/MobileNav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
85
frontend/src/components/layout/Sidebar.tsx
Normal file
85
frontend/src/components/layout/Sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
frontend/src/components/ui/badge.tsx
Normal file
26
frontend/src/components/ui/badge.tsx
Normal 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} />
|
||||
}
|
||||
35
frontend/src/components/ui/button.tsx
Normal file
35
frontend/src/components/ui/button.tsx
Normal 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'
|
||||
37
frontend/src/components/ui/card.tsx
Normal file
37
frontend/src/components/ui/card.tsx
Normal 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'
|
||||
17
frontend/src/components/ui/input.tsx
Normal file
17
frontend/src/components/ui/input.tsx
Normal 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'
|
||||
13
frontend/src/components/ui/label.tsx
Normal file
13
frontend/src/components/ui/label.tsx
Normal 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'
|
||||
22
frontend/src/components/ui/select.tsx
Normal file
22
frontend/src/components/ui/select.tsx
Normal 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'
|
||||
7
frontend/src/components/ui/spinner.tsx
Normal file
7
frontend/src/components/ui/spinner.tsx
Normal 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
39
frontend/src/index.css
Normal 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
51
frontend/src/lib/auth.tsx
Normal 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
6
frontend/src/lib/cn.ts
Normal 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))
|
||||
}
|
||||
38
frontend/src/lib/router.tsx
Normal file
38
frontend/src/lib/router.tsx
Normal 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
14
frontend/src/main.tsx
Normal 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>
|
||||
)
|
||||
146
frontend/src/pages/Dashboard.tsx
Normal file
146
frontend/src/pages/Dashboard.tsx
Normal 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
116
frontend/src/pages/Feed.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
frontend/src/pages/Login.tsx
Normal file
63
frontend/src/pages/Login.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
117
frontend/src/pages/Watchlist.tsx
Normal file
117
frontend/src/pages/Watchlist.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
192
frontend/src/pages/admin/AIProviders.tsx
Normal file
192
frontend/src/pages/admin/AIProviders.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
12
frontend/src/pages/admin/AdminLayout.tsx
Normal file
12
frontend/src/pages/admin/AdminLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
131
frontend/src/pages/admin/AdminSettings.tsx
Normal file
131
frontend/src/pages/admin/AdminSettings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
113
frontend/src/pages/admin/AdminUsers.tsx
Normal file
113
frontend/src/pages/admin/AdminUsers.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
114
frontend/src/pages/admin/Credentials.tsx
Normal file
114
frontend/src/pages/admin/Credentials.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
109
frontend/src/pages/admin/Jobs.tsx
Normal file
109
frontend/src/pages/admin/Jobs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
frontend/src/pages/admin/Sources.tsx
Normal file
56
frontend/src/pages/admin/Sources.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
frontend/tailwind.config.ts
Normal file
32
frontend/tailwind.config.ts
Normal 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
22
frontend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal 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
44
frontend/vite.config.ts
Normal 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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user