feat: causal lab - delete template + observable mapping + edge editing
- TabLibrary: bouton Supprimer (templates user uniquement) - TabEditor: inputMapping dans EditorState, panneau mapping visible quand noeud observable selectionne - TabEditor: edition d aretes (cliquer sur arête pour modifier style/force/signe/label) - Backend: GET /api/causal-lab/data-sources (prices/macro_series/ff_events) - analyze: auto-fetch market_watchlist+economic_events+ff_calendar depuis input_mapping - TabAnalyze: affiche inputs auto-recuperes vs manuels dans les resultats Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -23,11 +23,23 @@ export interface CausalEdge {
|
||||
label?: string
|
||||
}
|
||||
interface Coefficient { value: number; calibrated: number | null; description: string }
|
||||
interface InputMapping {
|
||||
source: 'user_input' | 'market_watchlist' | 'economic_events' | 'ff_calendar' | 'surprise' | 'actual_value' | 'impact_score_scaled' | 'impact_score_pct'
|
||||
key?: string
|
||||
field?: string
|
||||
unit?: string
|
||||
range?: number[]
|
||||
}
|
||||
interface DataSources {
|
||||
prices: { key: string; label: string }[]
|
||||
macro_series: { key: string; label: string }[]
|
||||
ff_events: { key: string; label: string }[]
|
||||
}
|
||||
export interface GraphJson {
|
||||
nodes: CausalNode[]; edges: CausalEdge[]
|
||||
coefficients: Record<string, Coefficient>
|
||||
instruments: string[]
|
||||
input_mapping: Record<string, { source: string; unit?: string; range?: number[] }>
|
||||
input_mapping: Record<string, InputMapping>
|
||||
}
|
||||
interface Template {
|
||||
id: number; name: string; category: string; sub_type: string
|
||||
@@ -324,6 +336,7 @@ function TabLibrary() {
|
||||
const [selected, setSelected] = useState<Template | null>(null)
|
||||
const [catFilter, setCatFilter] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [editCoefs, setEditCoefs] = useState<Record<string, number>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [apiError, setApiError] = useState<string | null>(null)
|
||||
@@ -360,6 +373,20 @@ function TabLibrary() {
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
async function deleteTemplate() {
|
||||
if (!selected) return
|
||||
if (!window.confirm(`Supprimer "${selected.name}" ? Cette action est irréversible.`)) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await api(`/api/causal-lab/template/${selected.id}`, { method: 'DELETE' })
|
||||
setSelected(null)
|
||||
const data = await api(`/api/causal-lab/templates${catFilter ? `?category=${catFilter}` : ''}`)
|
||||
setTemplates(data)
|
||||
} catch (e: unknown) {
|
||||
alert(String(e))
|
||||
} finally { setDeleting(false) }
|
||||
}
|
||||
|
||||
const cats = [...new Set(templates.map(t => t.category))]
|
||||
|
||||
return (
|
||||
@@ -411,9 +438,18 @@ function TabLibrary() {
|
||||
<h3 className="text-white font-semibold">{selected.name}</h3>
|
||||
<p className="text-slate-400 text-xs mt-1">{selected.description}</p>
|
||||
</div>
|
||||
<span className="text-xs px-2 py-1 rounded bg-purple-900/30 text-purple-300 border border-purple-700/40">
|
||||
v{selected.heuristic_ver}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className="text-xs px-2 py-1 rounded bg-purple-900/30 text-purple-300 border border-purple-700/40">
|
||||
v{selected.heuristic_ver}
|
||||
</span>
|
||||
{selected.created_by !== 'system' && (
|
||||
<button onClick={deleteTemplate} disabled={deleting}
|
||||
title="Supprimer ce template"
|
||||
className="p-1 rounded hover:bg-red-900/30 text-slate-500 hover:text-red-400 transition-colors disabled:opacity-40">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{selected.ai_rationale && (
|
||||
<div className="mt-3 p-3 bg-blue-900/10 border border-blue-800/30 rounded text-xs text-blue-200">
|
||||
@@ -476,12 +512,13 @@ interface EditorState {
|
||||
description: string; instruments: string[]; aiRationale: string
|
||||
nodes: CausalNode[]; edges: CausalEdge[]
|
||||
coefficients: Record<string, Coefficient>
|
||||
inputMapping: Record<string, InputMapping>
|
||||
}
|
||||
|
||||
const BLANK: EditorState = {
|
||||
templateId: null, name: 'Nouveau template', category: 'macro_us', subType: '',
|
||||
description: '', instruments: ['EURUSD'], aiRationale: '',
|
||||
nodes: [], edges: [], coefficients: {},
|
||||
nodes: [], edges: [], coefficients: {}, inputMapping: {},
|
||||
}
|
||||
|
||||
const INSTRUMENTS_LIST = ['EURUSD', 'XAUUSD', 'SP500', 'BRENT', 'US2Y', 'US10Y']
|
||||
@@ -489,28 +526,33 @@ const INSTRUMENTS_LIST = ['EURUSD', 'XAUUSD', 'SP500', 'BRENT', 'US2Y', 'US10Y']
|
||||
function TabEditor() {
|
||||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
const [state, setState] = useState<EditorState>(BLANK)
|
||||
const [selNode, setSelNode] = useState<string | null>(null) // id du nœud sélectionné pour repositionnement
|
||||
const [selNode, setSelNode] = useState<string | null>(null)
|
||||
const [editEdgeIdx, setEditEdge] = useState<number | null>(null)
|
||||
const [addNodeOpen, setAddNode] = useState(false)
|
||||
const [addEdgeOpen, setAddEdge] = useState(false)
|
||||
const [newNode, setNewNode] = useState<Partial<CausalNode>>({ type: 'observable', x: 200, y: 200 })
|
||||
const [newEdge, setNewEdge] = useState<Partial<CausalEdge>>({ style: 'solid', strength: 2, sign: 'positive' })
|
||||
const [editEdge, setEditEdgeVal] = useState<Partial<CausalEdge>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
const [dataSources, setDS] = useState<DataSources>({ prices: [], macro_series: [], ff_events: [] })
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
api('/api/causal-lab/templates').then(setTemplates)
|
||||
api('/api/causal-lab/data-sources').then(setDS).catch(() => {})
|
||||
}, [])
|
||||
|
||||
function loadTemplate(id: number) {
|
||||
const t = templates.find(x => x.id === id)
|
||||
if (!t) return
|
||||
setState({
|
||||
templateId: t.id, name: t.name + ' (copie)', category: t.category, subType: t.sub_type,
|
||||
templateId: t.id, name: t.name, category: t.category, subType: t.sub_type,
|
||||
description: t.description, instruments: t.instruments, aiRationale: t.ai_rationale,
|
||||
nodes: JSON.parse(JSON.stringify(t.graph_json.nodes || [])),
|
||||
edges: JSON.parse(JSON.stringify(t.graph_json.edges || [])),
|
||||
coefficients: JSON.parse(JSON.stringify(t.graph_json.coefficients || {})),
|
||||
inputMapping: JSON.parse(JSON.stringify(t.graph_json.input_mapping || {})),
|
||||
})
|
||||
setSelNode(null)
|
||||
}
|
||||
@@ -520,7 +562,8 @@ function TabEditor() {
|
||||
const graphJson: GraphJson = {
|
||||
nodes: state.nodes, edges: state.edges,
|
||||
coefficients: state.coefficients,
|
||||
instruments: state.instruments, input_mapping: {},
|
||||
instruments: state.instruments,
|
||||
input_mapping: state.inputMapping,
|
||||
}
|
||||
|
||||
// Click SVG background → reposition selected node
|
||||
@@ -557,11 +600,15 @@ function TabEditor() {
|
||||
}
|
||||
|
||||
function removeNode(id: string) {
|
||||
setState(s => ({
|
||||
...s,
|
||||
nodes: s.nodes.filter(n => n.id !== id),
|
||||
edges: s.edges.filter(e => e.from !== id && e.to !== id),
|
||||
}))
|
||||
setState(s => {
|
||||
const im = { ...s.inputMapping }; delete im[id]
|
||||
return {
|
||||
...s,
|
||||
nodes: s.nodes.filter(n => n.id !== id),
|
||||
edges: s.edges.filter(e => e.from !== id && e.to !== id),
|
||||
inputMapping: im,
|
||||
}
|
||||
})
|
||||
if (selNode === id) setSelNode(null)
|
||||
}
|
||||
|
||||
@@ -581,6 +628,22 @@ function TabEditor() {
|
||||
|
||||
function removeEdge(i: number) {
|
||||
setState(s => ({ ...s, edges: s.edges.filter((_, idx) => idx !== i) }))
|
||||
if (editEdgeIdx === i) setEditEdge(null)
|
||||
}
|
||||
|
||||
function startEditEdge(i: number) {
|
||||
const e = state.edges[i]
|
||||
setEditEdgeVal({ ...e })
|
||||
setEditEdge(i)
|
||||
}
|
||||
|
||||
function applyEdgeEdit() {
|
||||
if (editEdgeIdx === null) return
|
||||
setState(s => ({
|
||||
...s,
|
||||
edges: s.edges.map((e, i) => i === editEdgeIdx ? { ...e, ...editEdge } : e),
|
||||
}))
|
||||
setEditEdge(null)
|
||||
}
|
||||
|
||||
function autoDetectCoefs(nodes: CausalNode[], existing: Record<string, Coefficient>) {
|
||||
@@ -598,7 +661,7 @@ function TabEditor() {
|
||||
const body = {
|
||||
name: state.name, category: state.category, sub_type: state.subType,
|
||||
description: state.description, instruments: state.instruments,
|
||||
graph_json: { ...graphJson, input_mapping: {} },
|
||||
graph_json: graphJson,
|
||||
ai_rationale: state.aiRationale,
|
||||
}
|
||||
const r = await api('/api/causal-lab/template', {
|
||||
@@ -622,7 +685,7 @@ function TabEditor() {
|
||||
body: JSON.stringify({
|
||||
name: state.name, category: state.category, sub_type: state.subType,
|
||||
description: state.description, instruments: state.instruments,
|
||||
graph_json: { ...graphJson, input_mapping: {} },
|
||||
graph_json: graphJson,
|
||||
ai_rationale: state.aiRationale,
|
||||
}),
|
||||
})
|
||||
@@ -634,6 +697,7 @@ function TabEditor() {
|
||||
}
|
||||
|
||||
const nodeIds = state.nodes.map(n => n.id)
|
||||
const selectedNodeObj = selNode ? state.nodes.find(n => n.id === selNode) : null
|
||||
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
@@ -801,6 +865,75 @@ function TabEditor() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mapping observable */}
|
||||
{selectedNodeObj?.type === 'observable' && selNode && (() => {
|
||||
const mapping: InputMapping = state.inputMapping[selNode] || { source: 'user_input' }
|
||||
const updateMapping = (changes: Partial<InputMapping>) =>
|
||||
setState(s => ({
|
||||
...s,
|
||||
inputMapping: { ...s.inputMapping, [selNode]: { ...s.inputMapping[selNode], ...changes } },
|
||||
}))
|
||||
const clearMapping = () =>
|
||||
setState(s => { const m = { ...s.inputMapping }; delete m[selNode]; return { ...s, inputMapping: m } })
|
||||
const fieldOptions: Record<string, { key: string; label: string }[]> = {
|
||||
market_watchlist: [{ key: 'close', label: 'Prix (close)' }, { key: 'change_pct', label: 'Variation %' }],
|
||||
economic_events: [{ key: 'actual', label: 'Valeur actuelle' }, { key: 'surprise', label: 'Surprise (actual−forecast)' }],
|
||||
ff_calendar: [{ key: 'actual', label: 'Valeur actuelle' }, { key: 'surprise', label: 'Surprise' }],
|
||||
}
|
||||
const dsKeys = mapping.source === 'market_watchlist' ? dataSources.prices
|
||||
: mapping.source === 'economic_events' ? dataSources.macro_series
|
||||
: mapping.source === 'ff_calendar' ? dataSources.ff_events : []
|
||||
return (
|
||||
<div className="bg-dark-700 rounded-lg p-3 border border-cyan-700/40 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-cyan-400 font-semibold uppercase tracking-wider">
|
||||
Mapping — {selNode}
|
||||
</span>
|
||||
{mapping.source && mapping.source !== 'user_input' && (
|
||||
<button onClick={clearMapping} className="text-xs text-slate-500 hover:text-red-400">Effacer</button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-500">Source</label>
|
||||
<select value={mapping.source || 'user_input'}
|
||||
onChange={e => updateMapping({ source: e.target.value as InputMapping['source'], key: '', field: 'close' })}
|
||||
className="w-full mt-0.5 bg-dark-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-300">
|
||||
<option value="user_input">Manuel (saisie Analyser)</option>
|
||||
<option value="market_watchlist">Prix marché — yfinance</option>
|
||||
<option value="economic_events">Série macro — BD</option>
|
||||
<option value="ff_calendar">Calendrier Forex Factory</option>
|
||||
</select>
|
||||
</div>
|
||||
{mapping.source && mapping.source !== 'user_input' && (<>
|
||||
<div>
|
||||
<label className="text-xs text-slate-500">Clé</label>
|
||||
<input list={`ds-${selNode}`}
|
||||
value={mapping.key || ''}
|
||||
onChange={e => updateMapping({ key: e.target.value })}
|
||||
placeholder={
|
||||
mapping.source === 'market_watchlist' ? 'ex: EURUSD' :
|
||||
mapping.source === 'economic_events' ? 'ex: NFP_US' : 'ex: Non-Farm Payrolls'
|
||||
}
|
||||
className="w-full mt-0.5 bg-dark-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200" />
|
||||
<datalist id={`ds-${selNode}`}>
|
||||
{dsKeys.map(ds => <option key={ds.key} value={ds.key}>{ds.label}</option>)}
|
||||
</datalist>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-500">Champ</label>
|
||||
<select value={mapping.field || (mapping.source === 'market_watchlist' ? 'close' : 'actual')}
|
||||
onChange={e => updateMapping({ field: e.target.value })}
|
||||
className="w-full mt-0.5 bg-dark-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-300">
|
||||
{(fieldOptions[mapping.source] || []).map(f => (
|
||||
<option key={f.key} value={f.key}>{f.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</>)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Arêtes */}
|
||||
<div className="bg-dark-700 rounded-lg p-3 border border-slate-700/40">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
@@ -876,16 +1009,68 @@ function TabEditor() {
|
||||
|
||||
<div className="space-y-1 max-h-52 overflow-y-auto">
|
||||
{state.edges.map((e, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-2 py-1.5 rounded border border-slate-700/40 bg-dark-800/50 text-xs">
|
||||
<div className="w-8 h-0 flex-shrink-0"
|
||||
style={{ borderTop: `${edgeWidth(e.strength)}px ${e.style === 'dashed' ? 'dashed' : 'solid'} ${edgeColor(e.sign)}` }} />
|
||||
<span className="font-mono text-slate-400 truncate flex-1">
|
||||
{e.from} → {e.to}
|
||||
</span>
|
||||
{e.label && <span className="text-slate-500 truncate max-w-[60px]">{e.label}</span>}
|
||||
<button onClick={() => removeEdge(i)} className="text-slate-600 hover:text-red-400 flex-shrink-0">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
<div key={i}>
|
||||
<div className={clsx('flex items-center gap-2 px-2 py-1.5 rounded border text-xs cursor-pointer',
|
||||
editEdgeIdx === i
|
||||
? 'border-yellow-500/60 bg-yellow-900/10'
|
||||
: 'border-slate-700/40 bg-dark-800/50 hover:border-slate-500')}
|
||||
onClick={() => editEdgeIdx === i ? setEditEdge(null) : startEditEdge(i)}>
|
||||
<div className="w-8 h-0 flex-shrink-0"
|
||||
style={{ borderTop: `${edgeWidth(e.strength)}px ${e.style === 'dashed' ? 'dashed' : 'solid'} ${edgeColor(e.sign)}` }} />
|
||||
<span className="font-mono text-slate-400 truncate flex-1">
|
||||
{e.from} → {e.to}
|
||||
</span>
|
||||
{e.label && <span className="text-slate-500 truncate max-w-[60px]">{e.label}</span>}
|
||||
<button onClick={ev => { ev.stopPropagation(); removeEdge(i) }}
|
||||
className="text-slate-600 hover:text-red-400 flex-shrink-0">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
{editEdgeIdx === i && (
|
||||
<div className="mt-1 p-2 bg-dark-800 rounded border border-yellow-700/30 space-y-1.5 text-xs">
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
<div>
|
||||
<label className="text-slate-500">Style</label>
|
||||
<select value={editEdge.style ?? e.style}
|
||||
onChange={ev => setEditEdgeVal(p => ({ ...p, style: ev.target.value as 'solid'|'dashed' }))}
|
||||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-300 text-xs mt-0.5">
|
||||
<option value="solid">Direct ──</option>
|
||||
<option value="dashed">Indirect ╌╌</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-500">Force</label>
|
||||
<select value={editEdge.strength ?? e.strength}
|
||||
onChange={ev => setEditEdgeVal(p => ({ ...p, strength: parseInt(ev.target.value) as 1|2|3 }))}
|
||||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-300 text-xs mt-0.5">
|
||||
<option value={1}>1 — fin</option>
|
||||
<option value={2}>2 — normal</option>
|
||||
<option value={3}>3 — fort</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-500">Signe</label>
|
||||
<select value={editEdge.sign ?? e.sign}
|
||||
onChange={ev => setEditEdgeVal(p => ({ ...p, sign: ev.target.value as 'positive'|'negative'|'neutral' }))}
|
||||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-300 text-xs mt-0.5">
|
||||
<option value="positive">+ positif</option>
|
||||
<option value="negative">− négatif</option>
|
||||
<option value="neutral">∅ neutre</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-500">Label</label>
|
||||
<input value={editEdge.label ?? e.label ?? ''}
|
||||
onChange={ev => setEditEdgeVal(p => ({ ...p, label: ev.target.value }))}
|
||||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
|
||||
</div>
|
||||
<button onClick={applyEdgeEdit}
|
||||
className="w-full py-1 bg-yellow-700 hover:bg-yellow-600 rounded text-xs text-white">
|
||||
Appliquer
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{state.edges.length === 0 && (
|
||||
@@ -997,7 +1182,12 @@ function TabAnalyze() {
|
||||
|
||||
const currentTemplate = templates.find(t => t.id === selTmpl)
|
||||
const manualNodes = currentTemplate?.graph_json?.input_mapping
|
||||
? Object.entries(currentTemplate.graph_json.input_mapping).filter(([, c]) => c.source === 'user_input')
|
||||
? Object.entries(currentTemplate.graph_json.input_mapping).filter(([, c]) =>
|
||||
!c.source || c.source === 'user_input')
|
||||
: []
|
||||
const autoNodes = currentTemplate?.graph_json?.input_mapping
|
||||
? Object.entries(currentTemplate.graph_json.input_mapping).filter(([, c]) =>
|
||||
c.source && c.source !== 'user_input')
|
||||
: []
|
||||
|
||||
async function recommend() {
|
||||
@@ -1078,6 +1268,19 @@ function TabAnalyze() {
|
||||
{['EURUSD', 'XAUUSD', 'SP500', 'BRENT'].map(i => <option key={i} value={i}>{i}</option>)}
|
||||
</select>
|
||||
|
||||
{autoNodes.length > 0 && (
|
||||
<div className="bg-dark-800/60 rounded p-3 space-y-1.5 border border-cyan-700/20">
|
||||
<div className="text-xs text-cyan-500 font-semibold flex items-center gap-1.5">
|
||||
<Zap className="w-3 h-3" /> Auto-récupéré depuis BD
|
||||
</div>
|
||||
{autoNodes.map(([id, cfg]) => (
|
||||
<div key={id} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-slate-500 flex-1 capitalize">{id.replace(/_/g, ' ')}</span>
|
||||
<span className="text-cyan-400/70 text-xs font-mono">{cfg.key ?? cfg.source}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{manualNodes.length > 0 && (
|
||||
<div className="bg-dark-800 rounded p-3 space-y-2 border border-slate-700/30">
|
||||
<div className="text-xs text-slate-400 font-semibold">Inputs manuels</div>
|
||||
@@ -1146,6 +1349,25 @@ function TabAnalyze() {
|
||||
|
||||
{result && (
|
||||
<>
|
||||
{Object.keys(result.inputs).length > 0 && (
|
||||
<div className="bg-dark-700 rounded-lg p-3 border border-slate-700/40">
|
||||
<div className="text-xs text-slate-500 font-semibold uppercase tracking-wider mb-2">Inputs utilisés</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(result.inputs).map(([k, v]) => {
|
||||
const isAuto = autoNodes.some(([id]) => id === k)
|
||||
return (
|
||||
<span key={k} className={clsx('text-xs px-2 py-0.5 rounded border font-mono',
|
||||
isAuto
|
||||
? 'bg-cyan-900/20 text-cyan-300 border-cyan-700/30'
|
||||
: 'bg-slate-800 text-slate-300 border-slate-700/40')}>
|
||||
{k}: {typeof v === 'number' ? (v > 0 ? '+' : '') + v.toFixed(3) : String(v)}
|
||||
{isAuto && <span className="ml-1 text-cyan-600 text-xs">auto</span>}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-dark-700 rounded-lg p-4 border border-slate-700/40">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-white font-semibold text-sm">{result.event.name}</h3>
|
||||
|
||||
Reference in New Issue
Block a user