feat: instrument analysis
This commit is contained in:
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Sparkles, RefreshCw, ChevronDown, TrendingUp, TrendingDown,
|
||||
Minus, BarChart2, Clock, Calendar, AlertCircle, Pencil, Save, X, Plus, Trash2,
|
||||
Minus, BarChart2, Clock, Calendar, AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import axios from 'axios'
|
||||
import clsx from 'clsx'
|
||||
@@ -12,13 +12,15 @@ const api = axios.create({ baseURL: '/api' })
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Driver {
|
||||
key: string; label: string; weight: number; keywords: string[]; type?: string
|
||||
interface CausalTemplate {
|
||||
id: number; name: string; category: string; sub_type: string
|
||||
instruments: string[]; description: string
|
||||
graph_json: { nodes: { id: string; type: string; label: string; instrument?: string }[]; edges: any[] }
|
||||
}
|
||||
|
||||
interface InstrumentConfig {
|
||||
id: string; name: string; yf_ticker: string; category: string; currency: string
|
||||
description: string; drivers: Driver[]
|
||||
description: string
|
||||
regime_labels: string[]
|
||||
chart: { ma_periods: number[]; show_volume: boolean }
|
||||
correlation_instruments: string[]
|
||||
@@ -134,18 +136,41 @@ function isActiveAt(ev: SnapshotEvent, selectedDate: string | null): boolean {
|
||||
return diffDays <= 30
|
||||
}
|
||||
|
||||
// ── Driver type config ────────────────────────────────────────────────────────
|
||||
// ── Causal graph config ───────────────────────────────────────────────────────
|
||||
|
||||
const DRIVER_TYPES = ['event_calendar', 'report', 'geopolitical', 'fundamental', 'sentiment', 'technical'] as const
|
||||
type DriverType = typeof DRIVER_TYPES[number]
|
||||
// Maps snapshot event category → causal template category
|
||||
const EVENT_TO_CAUSAL_CAT: Record<string, string> = {
|
||||
macro_us: 'macro_us',
|
||||
macro_eu: 'macro_eu',
|
||||
geopolitical: 'geopolitical',
|
||||
report: 'report',
|
||||
sentiment: 'sentiment',
|
||||
commodity: 'commodity',
|
||||
fundamental: 'macro_us',
|
||||
event_calendar: 'macro_us',
|
||||
}
|
||||
|
||||
const DRIVER_TYPE_CFG: Record<string, { short: string; tw: string }> = {
|
||||
event_calendar: { short: 'Cal.', tw: 'text-amber-400 bg-amber-900/30 border-amber-700/30' },
|
||||
report: { short: 'Rep.', tw: 'text-blue-400 bg-blue-900/30 border-blue-700/30' },
|
||||
geopolitical: { short: 'Geo.', tw: 'text-red-400 bg-red-900/30 border-red-700/30' },
|
||||
fundamental: { short: 'Fund.', tw: 'text-emerald-400 bg-emerald-900/30 border-emerald-700/30' },
|
||||
sentiment: { short: 'Sent.', tw: 'text-violet-400 bg-violet-900/30 border-violet-700/30' },
|
||||
technical: { short: 'Tech.', tw: 'text-cyan-400 bg-cyan-900/30 border-cyan-700/30' },
|
||||
// Maps instrument dashboard category → causal lab instrument keys
|
||||
const CAT_TO_CAUSAL_INST: Record<string, string[]> = {
|
||||
equity_index: ['SP500'],
|
||||
equity_intl: ['SP500'],
|
||||
metal: ['XAUUSD'],
|
||||
energy: ['BRENT'],
|
||||
fx: ['EURUSD'],
|
||||
bond: ['US10Y', 'EU10Y'],
|
||||
credit: ['US10Y'],
|
||||
volatility: ['SP500'],
|
||||
stock: ['SP500'],
|
||||
crypto: [],
|
||||
}
|
||||
|
||||
const CAUSAL_CAT_TW: Record<string, string> = {
|
||||
macro_us: 'text-blue-400 border-blue-700/30 bg-blue-900/20',
|
||||
macro_eu: 'text-cyan-400 border-cyan-700/30 bg-cyan-900/20',
|
||||
geopolitical: 'text-red-400 border-red-700/30 bg-red-900/20',
|
||||
report: 'text-amber-400 border-amber-700/30 bg-amber-900/20',
|
||||
sentiment: 'text-violet-400 border-violet-700/30 bg-violet-900/20',
|
||||
commodity: 'text-orange-400 border-orange-700/30 bg-orange-900/20',
|
||||
}
|
||||
|
||||
// ── Macro regime colour mapping ───────────────────────────────────────────────
|
||||
@@ -525,122 +550,6 @@ function NarrativeCard({ narrative, loading, onLoad, instrument }: {
|
||||
)
|
||||
}
|
||||
|
||||
// ── DriversPanel — édition inline ────────────────────────────────────────────
|
||||
|
||||
function DriversPanel({ instrumentId, drivers, onSave, onClose }: {
|
||||
instrumentId: string
|
||||
drivers: Driver[]
|
||||
onSave: (drivers: Driver[]) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [local, setLocal] = useState<Driver[]>(() => drivers.map(d => ({ ...d, keywords: [...(d.keywords ?? [])] })))
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
function updateDriver(i: number, field: keyof Driver, val: any) {
|
||||
setLocal(prev => prev.map((d, idx) => idx === i ? { ...d, [field]: val } : d))
|
||||
}
|
||||
|
||||
function updateKeywords(i: number, raw: string) {
|
||||
const kws = raw.split(',').map(s => s.trim()).filter(Boolean)
|
||||
updateDriver(i, 'keywords', kws)
|
||||
}
|
||||
|
||||
function addDriver() {
|
||||
setLocal(prev => [...prev, {
|
||||
key: `driver_${Date.now()}`, label: 'Nouveau driver',
|
||||
weight: 0.5, keywords: [], type: 'fundamental',
|
||||
}])
|
||||
}
|
||||
|
||||
function removeDriver(i: number) {
|
||||
setLocal(prev => prev.filter((_, idx) => idx !== i))
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true); setError('')
|
||||
try {
|
||||
await api.put(`/instruments/${instrumentId}/drivers`, { drivers: local })
|
||||
onSave(local)
|
||||
onClose()
|
||||
} catch (e: any) {
|
||||
setError(e?.response?.data?.detail ?? 'Erreur de sauvegarde')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-blue-700/40 bg-dark-800/80 p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-white">Éditer les drivers — {instrumentId}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={addDriver}
|
||||
className="flex items-center gap-1 text-xs px-2.5 py-1 bg-blue-800/30 hover:bg-blue-800/50 text-blue-300 border border-blue-700/40 rounded-lg transition-colors">
|
||||
<Plus className="w-3 h-3" /> Ajouter
|
||||
</button>
|
||||
<button onClick={save} disabled={saving}
|
||||
className="flex items-center gap-1.5 text-xs px-3 py-1 bg-emerald-800/30 hover:bg-emerald-800/50 text-emerald-300 border border-emerald-700/40 rounded-lg transition-colors disabled:opacity-40">
|
||||
{saving ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Save className="w-3 h-3" />}
|
||||
{saving ? 'Sauvegarde...' : 'Sauvegarder'}
|
||||
</button>
|
||||
<button onClick={onClose} className="p-1 text-slate-500 hover:text-white transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-xs text-red-400 bg-red-900/20 border border-red-700/30 rounded-lg px-3 py-1.5">{error}</div>}
|
||||
|
||||
<div className="space-y-2 max-h-[420px] overflow-y-auto pr-1">
|
||||
{local.map((d, i) => (
|
||||
<div key={i} className="rounded-lg border border-slate-700/30 bg-dark-700/40 p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="flex-1 text-xs bg-dark-600 border border-slate-700/40 rounded px-2 py-1 text-white placeholder-slate-600"
|
||||
placeholder="Label"
|
||||
value={d.label}
|
||||
onChange={e => updateDriver(i, 'label', e.target.value)}
|
||||
/>
|
||||
{/* Type selector */}
|
||||
<select
|
||||
className="text-xs bg-dark-600 border border-slate-700/40 rounded px-1.5 py-1 text-slate-300"
|
||||
value={d.type ?? 'fundamental'}
|
||||
onChange={e => updateDriver(i, 'type', e.target.value)}
|
||||
>
|
||||
{DRIVER_TYPES.map(t => (
|
||||
<option key={t} value={t}>{DRIVER_TYPE_CFG[t]?.short ?? t}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-slate-500">Poids</span>
|
||||
<input
|
||||
type="number" min="0" max="1" step="0.05"
|
||||
className="w-16 text-xs bg-dark-600 border border-slate-700/40 rounded px-2 py-1 text-white"
|
||||
value={d.weight}
|
||||
onChange={e => updateDriver(i, 'weight', parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<button onClick={() => removeDriver(i)} className="p-1 text-slate-600 hover:text-red-400 transition-colors">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-600 mb-0.5 block">Mots-clés (séparés par virgule)</label>
|
||||
<input
|
||||
className="w-full text-xs bg-dark-600 border border-slate-700/40 rounded px-2 py-1 text-slate-300 placeholder-slate-600"
|
||||
placeholder="Fed, FOMC, pivot, QE, taux directeur..."
|
||||
value={(d.keywords ?? []).join(', ')}
|
||||
onChange={e => updateKeywords(i, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Macro gauge helpers ───────────────────────────────────────────────────────
|
||||
|
||||
const SCENARIO_META: Record<string, { label: string; emoji: string; color: string }> = {
|
||||
@@ -849,45 +758,89 @@ function EventTimeline({
|
||||
)
|
||||
}
|
||||
|
||||
// ── DriversValidation ─────────────────────────────────────────────────────────
|
||||
// ── EventGraphCards ───────────────────────────────────────────────────────────
|
||||
|
||||
function DriversValidation({
|
||||
drivers, events, selectedDate,
|
||||
function EventGraphCards({
|
||||
events, templates, selectedDate, instrumentCategory,
|
||||
}: {
|
||||
drivers: Driver[]
|
||||
events: SnapshotEvent[]
|
||||
templates: CausalTemplate[]
|
||||
selectedDate: string | null
|
||||
instrumentCategory: string
|
||||
}) {
|
||||
if (!drivers.length) {
|
||||
return <div className="text-xs text-slate-600 italic py-2">Aucun driver configuré</div>
|
||||
const causalInsts = CAT_TO_CAUSAL_INST[instrumentCategory] ?? []
|
||||
|
||||
const allCats = new Set(
|
||||
events.map(ev => EVENT_TO_CAUSAL_CAT[ev.category]).filter(Boolean)
|
||||
)
|
||||
const activeCats = new Set(
|
||||
events.filter(ev => isActiveAt(ev, selectedDate))
|
||||
.map(ev => EVENT_TO_CAUSAL_CAT[ev.category]).filter(Boolean)
|
||||
)
|
||||
|
||||
const relevant = templates.filter(t => allCats.has(t.category))
|
||||
|
||||
if (!relevant.length) {
|
||||
return (
|
||||
<div className="text-xs text-slate-600 italic py-3">
|
||||
Aucun graphe causal associé aux événements de cette période
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{drivers.map(d => {
|
||||
const matches = events.filter(ev => ev.category === d.type || ev.category === d.key)
|
||||
const active = matches.some(ev => isActiveAt(ev, selectedDate))
|
||||
const typeCfg = DRIVER_TYPE_CFG[d.type ?? '']
|
||||
{relevant.map(t => {
|
||||
const eventActive = activeCats.has(t.category)
|
||||
const instrumentTouched = causalInsts.some(ci => t.instruments.includes(ci))
|
||||
return (
|
||||
<div
|
||||
key={d.key}
|
||||
key={t.id}
|
||||
className={clsx(
|
||||
'rounded-lg border p-2.5 transition-colors',
|
||||
active ? 'border-emerald-600/50 bg-emerald-900/20' : 'border-slate-700/30 bg-dark-700/40'
|
||||
eventActive && instrumentTouched
|
||||
? 'border-emerald-600/50 bg-emerald-900/20'
|
||||
: eventActive
|
||||
? 'border-amber-600/40 bg-amber-900/10'
|
||||
: 'border-slate-700/30 bg-dark-700/40'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-1 mb-1.5">
|
||||
<span className={clsx('text-xs font-semibold leading-tight', active ? 'text-emerald-300' : 'text-slate-400')}>
|
||||
{d.label}
|
||||
<span className={clsx(
|
||||
'text-xs font-semibold leading-tight',
|
||||
eventActive && instrumentTouched ? 'text-emerald-300'
|
||||
: eventActive ? 'text-amber-300'
|
||||
: 'text-slate-500'
|
||||
)}>
|
||||
{t.name}
|
||||
</span>
|
||||
{typeCfg && (
|
||||
<span className={clsx('shrink-0 text-[10px] px-1 py-0.5 rounded border', typeCfg.tw)}>
|
||||
{typeCfg.short}
|
||||
</span>
|
||||
)}
|
||||
<span className={clsx(
|
||||
'shrink-0 text-[9px] px-1 py-0.5 rounded border',
|
||||
CAUSAL_CAT_TW[t.category] ?? 'text-slate-500 border-slate-700/30'
|
||||
)}>
|
||||
{t.category}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 mt-1 mb-1.5">
|
||||
{t.instruments.map(inst => (
|
||||
<span key={inst} className={clsx(
|
||||
'text-[9px] px-1 py-0 rounded border',
|
||||
causalInsts.includes(inst)
|
||||
? 'text-emerald-400 bg-emerald-900/30 border-emerald-700/40'
|
||||
: 'text-slate-600 border-slate-700/30'
|
||||
)}>{inst}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-slate-600">poids {d.weight.toFixed(2)}</span>
|
||||
<span className={clsx('w-2 h-2 rounded-full', active ? 'bg-emerald-400' : 'bg-slate-600')} />
|
||||
<span className={clsx('text-[9px]', eventActive ? 'text-amber-500' : 'text-slate-700')}>
|
||||
{eventActive ? '● actif' : '○ période'}
|
||||
</span>
|
||||
<span className={clsx(
|
||||
'w-2 h-2 rounded-full',
|
||||
eventActive && instrumentTouched ? 'bg-emerald-400'
|
||||
: eventActive ? 'bg-amber-500'
|
||||
: 'bg-slate-700'
|
||||
)} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -899,17 +852,24 @@ function DriversValidation({
|
||||
// ── ExplanationScore ──────────────────────────────────────────────────────────
|
||||
|
||||
function ExplanationScore({
|
||||
drivers, events, selectedDate,
|
||||
events, templates, selectedDate, instrumentCategory,
|
||||
}: {
|
||||
drivers: Driver[]
|
||||
events: SnapshotEvent[]
|
||||
templates: CausalTemplate[]
|
||||
selectedDate: string | null
|
||||
instrumentCategory: string
|
||||
}) {
|
||||
const totalWeight = drivers.reduce((s, d) => s + (d.weight || 0), 0)
|
||||
const activeWeight = drivers
|
||||
.filter(d => events.filter(ev => ev.category === d.type || ev.category === d.key).some(ev => isActiveAt(ev, selectedDate)))
|
||||
.reduce((s, d) => s + (d.weight || 0), 0)
|
||||
const score = totalWeight > 0 ? (activeWeight / totalWeight) * 100 : 0
|
||||
const causalInsts = CAT_TO_CAUSAL_INST[instrumentCategory] ?? []
|
||||
const allCats = new Set(events.map(ev => EVENT_TO_CAUSAL_CAT[ev.category]).filter(Boolean))
|
||||
const activeCats = new Set(
|
||||
events.filter(ev => isActiveAt(ev, selectedDate))
|
||||
.map(ev => EVENT_TO_CAUSAL_CAT[ev.category]).filter(Boolean)
|
||||
)
|
||||
const periodTmpl = templates.filter(t => allCats.has(t.category))
|
||||
const touchedActive = templates.filter(t =>
|
||||
activeCats.has(t.category) && causalInsts.some(ci => t.instruments.includes(ci))
|
||||
)
|
||||
const score = periodTmpl.length > 0 ? (touchedActive.length / periodTmpl.length) * 100 : 0
|
||||
const verdict = score >= 60 ? 'Bien expliqué' : score >= 30 ? 'Partiellement' : 'Peu lisible'
|
||||
const badgeCls = score >= 60
|
||||
? 'text-emerald-400 bg-emerald-900/30 border-emerald-700/40'
|
||||
@@ -927,6 +887,11 @@ function ExplanationScore({
|
||||
<span className={clsx('text-xs font-semibold px-2 py-1 rounded-lg border whitespace-nowrap', badgeCls)}>
|
||||
{Math.round(score)}% — {verdict}
|
||||
</span>
|
||||
{periodTmpl.length > 0 && (
|
||||
<span className="text-[10px] text-slate-600 whitespace-nowrap">
|
||||
{touchedActive.length}/{periodTmpl.length} graphes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -949,15 +914,15 @@ export default function InstrumentDashboard() {
|
||||
const [loadingNarr, setLoadingNarr] = useState(false)
|
||||
const [selectorOpen, setSelectorOpen] = useState(false)
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null)
|
||||
const [editDrivers, setEditDrivers] = useState(false)
|
||||
const [tabUnder, setTabUnder] = useState<'counters' | 'analyse'>('counters')
|
||||
const [localDrivers, setLocalDrivers] = useState<Driver[] | null>(null)
|
||||
const [macroAtDate, setMacroAtDate] = useState<MacroGaugeSnap | null>(null)
|
||||
const [tabUnder, setTabUnder] = useState<'counters' | 'analyse'>('counters')
|
||||
const [templates, setTemplates] = useState<CausalTemplate[]>([])
|
||||
const [macroAtDate, setMacroAtDate] = useState<MacroGaugeSnap | null>(null)
|
||||
|
||||
const instrumentId = id.toUpperCase()
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/instruments').then(r => setInstruments(r.data)).catch(() => {})
|
||||
api.get('/causal-lab/templates').then(r => setTemplates(r.data)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -965,8 +930,6 @@ export default function InstrumentDashboard() {
|
||||
setSnapshot(null)
|
||||
setNarrative('')
|
||||
setSelectedDate(null)
|
||||
setLocalDrivers(null)
|
||||
setEditDrivers(false)
|
||||
setMacroAtDate(null)
|
||||
api.get(`/instruments/${instrumentId}/snapshot?period=${period}`)
|
||||
.then(r => {
|
||||
@@ -1075,8 +1038,6 @@ export default function InstrumentDashboard() {
|
||||
const dateLabel = effectiveDate ? fmtDateFR(effectiveDate) : '—'
|
||||
const displayPrice = dateTrend?.current_price ?? snapshot?.current_price
|
||||
|
||||
const activeDrivers = localDrivers ?? snapshot?.instrument?.drivers ?? []
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-screen-xl mx-auto space-y-4">
|
||||
|
||||
@@ -1137,18 +1098,6 @@ export default function InstrumentDashboard() {
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{snapshot && (
|
||||
<button onClick={() => setEditDrivers(e => !e)}
|
||||
className={clsx('flex items-center gap-1.5 text-xs px-3 py-1.5 border rounded-lg transition-colors',
|
||||
editDrivers
|
||||
? 'bg-blue-800/40 text-blue-300 border-blue-700/40'
|
||||
: 'bg-dark-800 text-slate-400 border-slate-700/40 hover:text-white hover:bg-dark-700'
|
||||
)}
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
Drivers
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-1 bg-dark-800 border border-slate-700/40 rounded-xl p-1">
|
||||
{PERIODS.map(p => (
|
||||
<button key={p.key} onClick={() => setPeriod(p.key)}
|
||||
@@ -1261,38 +1210,33 @@ export default function InstrumentDashboard() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Zone basse — validation des drivers */}
|
||||
{/* Zone basse — graphes causaux liés aux événements */}
|
||||
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<BarChart2 className="w-4 h-4 text-violet-400" />
|
||||
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Validation des drivers</span>
|
||||
<span className="text-xs text-slate-600 ml-auto">{fmtDateFR(effectiveDate)}</span>
|
||||
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Graphes causaux</span>
|
||||
<span className="text-xs text-slate-600 ml-auto">
|
||||
vert = actif + touche l'instrument · ambre = actif · gris = période seulement
|
||||
</span>
|
||||
</div>
|
||||
<DriversValidation
|
||||
drivers={activeDrivers}
|
||||
<EventGraphCards
|
||||
events={snapshot.events}
|
||||
templates={templates}
|
||||
selectedDate={effectiveDate}
|
||||
instrumentCategory={selected?.category ?? ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Note globale */}
|
||||
<ExplanationScore
|
||||
drivers={activeDrivers}
|
||||
events={snapshot.events}
|
||||
templates={templates}
|
||||
selectedDate={effectiveDate}
|
||||
instrumentCategory={selected?.category ?? ''}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editDrivers && (
|
||||
<DriversPanel
|
||||
instrumentId={instrumentId}
|
||||
drivers={activeDrivers}
|
||||
onSave={saved => { setLocalDrivers(saved); setEditDrivers(false) }}
|
||||
onClose={() => setEditDrivers(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NarrativeCard
|
||||
narrative={narrative}
|
||||
loading={loadingNarr}
|
||||
|
||||
Reference in New Issue
Block a user