feat: onglet Idées — 2 espaces recherche avec chart + notes persistantes

- nouvel onglet 💡 Idées entre Marchés et Calendrier
- 2 slots indépendants (Recherche A / B) avec :
  - graphique TradingView (symbole libre + dropdown 15 paires, 4 timeframes)
  - zone de notes avec titre, texte, coller image (Ctrl+V), ↗ agrandir plein écran
  - persistance automatique localStorage (symbole, interval, notes, images)
  - bouton Archiver : sauvegarde + reset du slot
- panel Archives collapsible avec aperçu notes et miniatures images

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
caty21
2026-06-29 17:58:21 +02:00
parent 14e853338d
commit 333ce5947a
2 changed files with 403 additions and 2 deletions
+6 -2
View File
@@ -14,6 +14,7 @@ import YieldsTab from "@/components/YieldsTab";
import NewsTab from "@/components/NewsTab"; import NewsTab from "@/components/NewsTab";
import CotTab from "@/components/CotTab"; import CotTab from "@/components/CotTab";
import ReportTab from "@/components/ReportTab"; import ReportTab from "@/components/ReportTab";
import IdeesTab from "@/components/IdeesTab";
import { TvAdvancedChart } from "@/components/TvChart"; import { TvAdvancedChart } from "@/components/TvChart";
import type { CalendarEvent } from "@/app/api/calendar/route"; import type { CalendarEvent } from "@/app/api/calendar/route";
import type { NewsItem } from "@/app/api/news/route"; import type { NewsItem } from "@/app/api/news/route";
@@ -29,7 +30,7 @@ export default function Dashboard() {
const [cot, setCot] = useState<Record<string, CotEntry> | null>(null); const [cot, setCot] = useState<Record<string, CotEntry> | null>(null);
const [calEvents, setCalEvents] = useState<CalendarEvent[]>([]); const [calEvents, setCalEvents] = useState<CalendarEvent[]>([]);
const [nextWeekAvail, setNextWeekAvail] = useState(false); const [nextWeekAvail, setNextWeekAvail] = useState(false);
const [activeTab, setActiveTab] = useState<"dashboard" | "calendar" | "pairs" | "yields" | "news" | "cot" | "report" | "markets">("dashboard"); const [activeTab, setActiveTab] = useState<"dashboard" | "calendar" | "pairs" | "yields" | "news" | "cot" | "report" | "markets" | "idees">("dashboard");
const [newsItems, setNewsItems] = useState<NewsItem[]>([]); const [newsItems, setNewsItems] = useState<NewsItem[]>([]);
const [newsLoading, setNewsLoading] = useState(false); const [newsLoading, setNewsLoading] = useState(false);
const [cotHistory, setCotHistory] = useState<CotHistory | null>(null); const [cotHistory, setCotHistory] = useState<CotHistory | null>(null);
@@ -340,7 +341,7 @@ export default function Dashboard() {
{/* Tab navigation */} {/* Tab navigation */}
<div className="flex gap-0 border-b border-slate-800 mb-4"> <div className="flex gap-0 border-b border-slate-800 mb-4">
{(["dashboard", "markets", "calendar", "pairs", "yields", "news", "cot", "report"] as const).map((tab) => ( {(["dashboard", "markets", "idees", "calendar", "pairs", "yields", "news", "cot", "report"] as const).map((tab) => (
<button <button
key={tab} key={tab}
onClick={() => setActiveTab(tab)} onClick={() => setActiveTab(tab)}
@@ -352,6 +353,7 @@ export default function Dashboard() {
> >
{tab === "dashboard" ? "Dashboard" {tab === "dashboard" ? "Dashboard"
: tab === "markets" ? "🌍 Marchés" : tab === "markets" ? "🌍 Marchés"
: tab === "idees" ? "💡 Idées"
: tab === "calendar" ? "📅 Calendrier" : tab === "calendar" ? "📅 Calendrier"
: tab === "pairs" ? "↕ Paires" : tab === "pairs" ? "↕ Paires"
: tab === "yields" ? "📈 Yields 10Y" : tab === "yields" ? "📈 Yields 10Y"
@@ -627,6 +629,8 @@ export default function Dashboard() {
<CotTab history={cotHistory} loading={cotLoading} /> <CotTab history={cotHistory} loading={cotLoading} />
)} )}
{activeTab === "idees" && <IdeesTab />}
{activeTab === "report" && ( {activeTab === "report" && (
<ReportTab calEvents={calEvents} drivers={drivers} cotHistory={cotHistory} /> <ReportTab calEvents={calEvents} drivers={drivers} cotHistory={cotHistory} />
)} )}
+397
View File
@@ -0,0 +1,397 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { TvAdvancedChart } from "./TvChart";
// ── Constantes ────────────────────────────────────────────────────────────────
const INTERVALS: { v: string; l: string }[] = [
{ v: "60", l: "1h" }, { v: "240", l: "4h" },
{ v: "D", l: "1J" }, { v: "W", l: "1S" },
];
const QUICK_SYMBOLS = [
{ label: "EUR/USD", value: "FX:EURUSD" },
{ label: "GBP/USD", value: "FX:GBPUSD" },
{ label: "USD/JPY", value: "FX:USDJPY" },
{ label: "AUD/USD", value: "FX:AUDUSD" },
{ label: "NZD/USD", value: "FX:NZDUSD" },
{ label: "USD/CAD", value: "FX:USDCAD" },
{ label: "USD/CHF", value: "FX:USDCHF" },
{ label: "EUR/GBP", value: "FX:EURGBP" },
{ label: "EUR/JPY", value: "FX:EURJPY" },
{ label: "GBP/JPY", value: "FX:GBPJPY" },
{ label: "S&P 500", value: "FOREXCOM:SPXUSD" },
{ label: "DXY", value: "CAPITALCOM:DXY" },
{ label: "Or", value: "TVC:GOLD" },
{ label: "VIX", value: "PEPPERSTONE:VIX" },
{ label: "BTC/USD", value: "BINANCE:BTCUSDT" },
];
// ── Types ─────────────────────────────────────────────────────────────────────
interface NoteImage { id: string; dataUrl: string; }
interface SlotState {
symbol: string;
interval: string;
title: string;
notes: string;
images: NoteImage[];
}
interface Archive {
id: string;
savedAt: string;
slot: SlotState;
}
// ── LocalStorage helpers ──────────────────────────────────────────────────────
const LS_SLOTS = "ideas_slots_v1";
const LS_ARCHIVES = "ideas_archives_v1";
function loadLS<T>(key: string, fb: T): T {
if (typeof window === "undefined") return fb;
try { const r = localStorage.getItem(key); return r ? JSON.parse(r) as T : fb; } catch { return fb; }
}
function saveLS(key: string, val: unknown) {
try { localStorage.setItem(key, JSON.stringify(val)); } catch {}
}
const DEFAULT_SLOT = (symbol = "FX:EURUSD"): SlotState => ({
symbol, interval: "240", title: "", notes: "", images: [],
});
// ── NotePane ──────────────────────────────────────────────────────────────────
function NotePane({ slot, onChange }: { slot: SlotState; onChange: (s: SlotState) => void }) {
const [expanded, setExpanded] = useState(false);
const handlePaste = useCallback((e: React.ClipboardEvent) => {
const imgItem = Array.from(e.clipboardData.items).find(i => i.type.startsWith("image/"));
if (!imgItem) return;
e.preventDefault();
const file = imgItem.getAsFile();
if (!file) return;
const reader = new FileReader();
reader.onload = ev => {
const dataUrl = ev.target?.result as string;
onChange({ ...slot, images: [...slot.images, { id: Date.now().toString(), dataUrl }] });
};
reader.readAsDataURL(file);
}, [slot, onChange]);
return (
<>
{/* Overlay expanded */}
{expanded && (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-6" onClick={() => setExpanded(false)}>
<div className="bg-slate-900 border border-slate-700 rounded-2xl p-5 w-full max-w-3xl max-h-[90vh] overflow-auto shadow-2xl" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-3">
<span className="text-[11px] font-semibold text-slate-300">{slot.title || slot.symbol}</span>
<button onClick={() => setExpanded(false)} className="text-slate-500 hover:text-white text-sm"></button>
</div>
<textarea
className="w-full bg-slate-800/60 border border-slate-700/40 rounded-lg p-3 text-[12px] text-slate-200 outline-none resize-none focus:border-slate-500 h-[50vh]"
value={slot.notes}
onChange={e => onChange({ ...slot, notes: e.target.value })}
onPaste={handlePaste}
placeholder="Notes…"
/>
{slot.images.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{slot.images.map(img => (
<div key={img.id} className="relative group">
<img src={img.dataUrl} alt="" className="h-28 w-auto rounded-lg border border-slate-700/40 object-cover" />
<button
onClick={() => onChange({ ...slot, images: slot.images.filter(i => i.id !== img.id) })}
className="absolute -top-1.5 -right-1.5 w-5 h-5 bg-red-500 rounded-full text-white text-[9px] hidden group-hover:flex items-center justify-center"
></button>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* Inline note */}
<div className="flex flex-col h-full gap-2">
<input
className="bg-transparent border-b border-slate-700/50 text-[10px] text-slate-300 placeholder-slate-600 outline-none pb-1 shrink-0"
placeholder="Titre / hypothèse…"
value={slot.title}
onChange={e => onChange({ ...slot, title: e.target.value })}
/>
<textarea
className="flex-1 bg-slate-800/30 border border-slate-700/30 rounded-lg p-2.5 text-[10px] text-slate-300 placeholder-slate-600 outline-none resize-none focus:border-slate-600 transition-all min-h-[100px]"
placeholder={"Notes, niveaux clés…\nCtrl+V pour coller une image"}
value={slot.notes}
onChange={e => onChange({ ...slot, notes: e.target.value })}
onPaste={handlePaste}
/>
{/* Thumbnails */}
{slot.images.length > 0 && (
<div className="flex flex-wrap gap-1 shrink-0">
{slot.images.map(img => (
<div key={img.id} className="relative group">
<img src={img.dataUrl} alt="" className="h-12 w-auto rounded border border-slate-700/40 object-cover" />
<button
onClick={() => onChange({ ...slot, images: slot.images.filter(i => i.id !== img.id) })}
className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 rounded-full text-white text-[8px] hidden group-hover:flex items-center justify-center"
></button>
</div>
))}
</div>
)}
{/* Expand button */}
<button
onClick={() => setExpanded(true)}
className="text-[8px] text-slate-600 hover:text-sky-400 transition-colors self-end shrink-0"
>
Agrandir
</button>
</div>
</>
);
}
// ── SymbolPicker ──────────────────────────────────────────────────────────────
function SymbolPicker({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const [input, setInput] = useState(value);
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => { setInput(value); }, [value]);
useEffect(() => {
function onClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", onClickOutside);
return () => document.removeEventListener("mousedown", onClickOutside);
}, []);
const apply = () => {
const s = input.trim().toUpperCase();
if (!s) return;
onChange(s.includes(":") ? s : `FX:${s}`);
setOpen(false);
};
const filtered = QUICK_SYMBOLS.filter(s =>
s.label.toLowerCase().includes(input.toLowerCase()) ||
s.value.toLowerCase().includes(input.toLowerCase())
);
return (
<div ref={ref} className="relative flex-1 min-w-0">
<input
className="bg-slate-700/40 border border-slate-700/50 rounded px-2 py-1 text-[10px] text-slate-200 outline-none w-full font-mono focus:border-slate-500 transition-colors"
value={input}
onChange={e => { setInput(e.target.value); setOpen(true); }}
onKeyDown={e => e.key === "Enter" && apply()}
onFocus={() => setOpen(true)}
placeholder="EURUSD, FX:GBPUSD…"
/>
{open && filtered.length > 0 && (
<div className="absolute top-full left-0 mt-1 z-30 bg-slate-800 border border-slate-700 rounded-lg shadow-xl w-full min-w-[200px] py-1 max-h-48 overflow-y-auto">
{filtered.map(s => (
<button
key={s.value}
className="w-full text-left px-3 py-1.5 flex items-center justify-between text-[10px] hover:bg-slate-700 transition-colors"
onMouseDown={e => { e.preventDefault(); onChange(s.value); setInput(s.value); setOpen(false); }}
>
<span className="text-slate-200">{s.label}</span>
<span className="text-slate-600 text-[8px] font-mono">{s.value}</span>
</button>
))}
</div>
)}
</div>
);
}
// ── ResearchSlot ──────────────────────────────────────────────────────────────
function ResearchSlot({
slot, label, onChange, onArchive,
}: {
slot: SlotState;
label: string;
onChange: (s: SlotState) => void;
onArchive: () => void;
}) {
return (
<div className="border border-slate-700/30 rounded-xl overflow-hidden bg-slate-800/10">
{/* Toolbar */}
<div className="flex items-center gap-2 px-3 py-2 bg-slate-800/50 border-b border-slate-700/30">
<span className="text-[8px] font-bold text-slate-600 uppercase tracking-widest shrink-0">{label}</span>
<SymbolPicker value={slot.symbol} onChange={symbol => onChange({ ...slot, symbol })} />
{/* Interval */}
<div className="flex gap-0.5 shrink-0">
{INTERVALS.map(iv => (
<button
key={iv.v}
onClick={() => onChange({ ...slot, interval: iv.v })}
className={`text-[8px] px-1.5 py-0.5 rounded font-mono transition-colors ${
slot.interval === iv.v
? "bg-sky-500/20 text-sky-400 border border-sky-500/30"
: "text-slate-600 hover:text-slate-400"
}`}
>{iv.l}</button>
))}
</div>
{/* Archive */}
<button
onClick={onArchive}
className="ml-auto shrink-0 text-[8px] text-slate-600 hover:text-amber-400 border border-slate-700/40 hover:border-amber-500/30 px-2 py-1 rounded transition-colors flex items-center gap-1"
title="Archiver et libérer l'espace"
>
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/>
</svg>
Archiver
</button>
</div>
{/* Chart + Notes */}
<div className="grid grid-cols-[3fr_2fr]">
{/* Chart */}
<div className="border-r border-slate-700/30">
<TvAdvancedChart
key={`${slot.symbol}_${slot.interval}`}
symbol={slot.symbol}
interval={slot.interval}
height={380}
/>
</div>
{/* Notes */}
<div className="p-3 flex flex-col" style={{ minHeight: 380 }}>
<NotePane slot={slot} onChange={onChange} />
</div>
</div>
</div>
);
}
// ── Archives panel ────────────────────────────────────────────────────────────
function ArchivesPanel({ archives, onDelete }: { archives: Archive[]; onDelete: (id: string) => void }) {
const [open, setOpen] = useState(false);
if (!archives.length) return null;
return (
<div className="border border-slate-700/30 rounded-xl overflow-hidden">
<button
onClick={() => setOpen(v => !v)}
className="w-full flex items-center justify-between px-4 py-2.5 bg-slate-800/40 text-[10px] text-slate-400 hover:text-slate-200 transition-colors"
>
<span className="flex items-center gap-2">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/>
</svg>
Archives {archives.length} recherche{archives.length > 1 ? "s" : ""}
</span>
<span className="text-slate-600">{open ? "▲" : "▼"}</span>
</button>
{open && (
<div className="divide-y divide-slate-700/20">
{archives.map(a => (
<div key={a.id} className="px-4 py-3 flex items-start justify-between gap-4 hover:bg-slate-800/20 transition-colors">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-[10px] font-semibold text-slate-300 font-mono">{a.slot.symbol}</span>
<span className="text-[8px] text-slate-600 border border-slate-700/50 rounded px-1">{a.slot.interval}</span>
{a.slot.title && <span className="text-[9px] text-slate-400 truncate">{a.slot.title}</span>}
</div>
<p className="text-[8px] text-slate-600">
{new Date(a.savedAt).toLocaleDateString("fr-FR", { day: "2-digit", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" })}
</p>
{a.slot.notes && (
<p className="text-[9px] text-slate-500 mt-1 line-clamp-2 max-w-xl">{a.slot.notes}</p>
)}
{a.slot.images.length > 0 && (
<div className="flex gap-1 mt-1.5">
{a.slot.images.slice(0, 4).map(img => (
<img key={img.id} src={img.dataUrl} alt="" className="h-8 w-auto rounded border border-slate-700/40 object-cover" />
))}
{a.slot.images.length > 4 && (
<span className="text-[8px] text-slate-600 self-end">+{a.slot.images.length - 4}</span>
)}
</div>
)}
</div>
<button
onClick={() => onDelete(a.id)}
className="text-[8px] text-red-500/40 hover:text-red-400 shrink-0 transition-colors mt-0.5"
>Supprimer</button>
</div>
))}
</div>
)}
</div>
);
}
// ── IdeesTab (export) ─────────────────────────────────────────────────────────
export default function IdeesTab() {
const [slots, setSlots] = useState<[SlotState, SlotState]>([
DEFAULT_SLOT("FX:EURUSD"),
DEFAULT_SLOT("FX:GBPUSD"),
]);
const [archives, setArchives] = useState<Archive[]>([]);
useEffect(() => {
setSlots(loadLS<[SlotState, SlotState]>(LS_SLOTS, [DEFAULT_SLOT("FX:EURUSD"), DEFAULT_SLOT("FX:GBPUSD")]));
setArchives(loadLS<Archive[]>(LS_ARCHIVES, []));
}, []);
const updateSlot = useCallback((idx: 0 | 1, s: SlotState) => {
setSlots(prev => {
const next: [SlotState, SlotState] = [prev[0], prev[1]];
next[idx] = s;
saveLS(LS_SLOTS, next);
return next;
});
}, []);
const archiveSlot = useCallback((idx: 0 | 1) => {
const entry: Archive = { id: Date.now().toString(), savedAt: new Date().toISOString(), slot: slots[idx] };
const next = [entry, ...archives];
setArchives(next);
saveLS(LS_ARCHIVES, next);
const reset = DEFAULT_SLOT(idx === 0 ? "FX:EURUSD" : "FX:GBPUSD");
setSlots(prev => {
const n: [SlotState, SlotState] = [prev[0], prev[1]];
n[idx] = reset;
saveLS(LS_SLOTS, n);
return n;
});
}, [slots, archives]);
const deleteArchive = useCallback((id: string) => {
const next = archives.filter(a => a.id !== id);
setArchives(next);
saveLS(LS_ARCHIVES, next);
}, [archives]);
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-4">
<div className="h-px flex-1 bg-purple-500/20" />
<span className="text-purple-400 text-xs font-bold uppercase tracking-[0.3em]">Espace Idées · 2 recherches</span>
<div className="h-px flex-1 bg-purple-500/20" />
</div>
<ResearchSlot slot={slots[0]} label="Recherche A" onChange={s => updateSlot(0, s)} onArchive={() => archiveSlot(0)} />
<ResearchSlot slot={slots[1]} label="Recherche B" onChange={s => updateSlot(1, s)} onArchive={() => archiveSlot(1)} />
<ArchivesPanel archives={archives} onDelete={deleteArchive} />
</div>
);
}