mirror of
https://github.com/caty21/forex-dashboard.git
synced 2026-07-27 20:37:45 +00:00
feat(COT/Idees): redesign COT chart + NC group + editeur riche IdeesTab
COT : - Ajoute groupe NC (Non-Commercial Legacy) via Socrata API CFTC - Graphique bidirectionnel (barres avec gradient, zéro centré, track complet) - Barre L/S split sous chaque groupe, valeur nette + %L empilés - Corrige verdict bug : amDominates vérifié avant hfIsShort - Supprime 'k contrats', ajoute légende ΔL/ΔS/ΔNet IdeesTab : - Réécriture NotePane : contentEditable Notion-like avec images inline - Toolbar riche : gras, italique, souligné, listes, alignement - Redimensionnement image inline (25/40/60/80/100%) - Archives : affichage complet screenshot + texte, restauration vers slot actif Sentiment DXM : - Affichage brut paire par paire (Myfxbook) pour vérification directe - SentimentPair type + pairs[] dans SentimentEntry Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+345
-114
@@ -30,14 +30,11 @@ const QUICK_SYMBOLS = [
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface NoteImage { id: string; dataUrl: string; }
|
||||
|
||||
interface SlotState {
|
||||
symbol: string;
|
||||
interval: string;
|
||||
title: string;
|
||||
notes: string;
|
||||
images: NoteImage[];
|
||||
notes: string; // HTML (images inline en base64)
|
||||
}
|
||||
|
||||
interface Archive {
|
||||
@@ -60,13 +57,103 @@ function saveLS(key: string, val: unknown) {
|
||||
}
|
||||
|
||||
const DEFAULT_SLOT = (symbol = "FX:EURUSD"): SlotState => ({
|
||||
symbol, interval: "240", title: "", notes: "", images: [],
|
||||
symbol, interval: "240", title: "", notes: "",
|
||||
});
|
||||
|
||||
// ── NotePane ──────────────────────────────────────────────────────────────────
|
||||
// ── Toolbar de formatage ──────────────────────────────────────────────────────
|
||||
|
||||
function NotePane({ slot, onChange }: { slot: SlotState; onChange: (s: SlotState) => void }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const TOOLBAR_GROUPS = [
|
||||
[
|
||||
{ cmd: "bold", label: "B", cls: "font-bold", title: "Gras (Ctrl+B)" },
|
||||
{ cmd: "italic", label: "I", cls: "italic", title: "Italique (Ctrl+I)" },
|
||||
{ cmd: "underline", label: "U", cls: "underline", title: "Souligné (Ctrl+U)" },
|
||||
],
|
||||
[
|
||||
{ cmd: "insertUnorderedList", label: "•", cls: "", title: "Liste à puces" },
|
||||
{ cmd: "insertOrderedList", label: "1.", cls: "", title: "Liste numérotée" },
|
||||
],
|
||||
[
|
||||
{ cmd: "justifyLeft", label: "⬱", cls: "", title: "Aligner à gauche" },
|
||||
{ cmd: "justifyCenter", label: "≡", cls: "", title: "Centrer" },
|
||||
{ cmd: "justifyRight", label: "⬰", cls: "", title: "Aligner à droite" },
|
||||
],
|
||||
];
|
||||
|
||||
function FormatToolbar({ editorRef, onSave, selImg, onResizeImg, onDeleteImg }: {
|
||||
editorRef: React.RefObject<HTMLDivElement>;
|
||||
onSave: () => void;
|
||||
selImg: HTMLImageElement | null;
|
||||
onResizeImg: (pct: number) => void;
|
||||
onDeleteImg: () => void;
|
||||
}) {
|
||||
const exec = (cmd: string) => {
|
||||
editorRef.current?.focus();
|
||||
document.execCommand(cmd, false);
|
||||
onSave();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 bg-slate-800/70 border border-slate-700/40 rounded-lg px-2 py-1 shrink-0 flex-wrap">
|
||||
{TOOLBAR_GROUPS.map((group, gi) => (
|
||||
<div key={gi} className="flex items-center gap-0.5">
|
||||
{gi > 0 && <div className="w-px h-3.5 bg-slate-700 mx-1" />}
|
||||
{group.map(b => (
|
||||
<button
|
||||
key={b.cmd}
|
||||
title={b.title}
|
||||
onMouseDown={e => { e.preventDefault(); exec(b.cmd); }}
|
||||
className={`w-6 h-6 rounded text-[10px] ${b.cls} text-slate-400 hover:text-white hover:bg-slate-600/60 transition-colors`}
|
||||
>{b.label}</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Toolbar image si sélectionnée */}
|
||||
{selImg && (
|
||||
<>
|
||||
<div className="w-px h-3.5 bg-slate-700 mx-1" />
|
||||
<span className="text-[8px] text-slate-500">Image :</span>
|
||||
{[25, 40, 60, 80, 100].map(p => (
|
||||
<button key={p}
|
||||
onMouseDown={e => { e.preventDefault(); onResizeImg(p); }}
|
||||
className="text-[8px] px-1.5 py-0.5 rounded bg-slate-700/60 text-slate-300 hover:bg-sky-500/20 hover:text-sky-300 transition-colors"
|
||||
>{p}%</button>
|
||||
))}
|
||||
<button
|
||||
onMouseDown={e => { e.preventDefault(); onDeleteImg(); }}
|
||||
className="text-[8px] text-red-400/60 hover:text-red-400 ml-1 transition-colors"
|
||||
>✕</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── RichEditor ────────────────────────────────────────────────────────────────
|
||||
|
||||
function RichEditor({
|
||||
html, onChange, className, style, showToolbar = true,
|
||||
}: {
|
||||
html: string;
|
||||
onChange: (h: string) => void;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
showToolbar?: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const skipRef = useRef(false);
|
||||
const [selImg, setSelImg] = useState<HTMLImageElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current || ref.current.innerHTML === html) return;
|
||||
skipRef.current = true;
|
||||
ref.current.innerHTML = html;
|
||||
}, [html]);
|
||||
|
||||
const save = useCallback(() => {
|
||||
if (skipRef.current) { skipRef.current = false; return; }
|
||||
onChange(ref.current?.innerHTML ?? "");
|
||||
}, [onChange]);
|
||||
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
||||
const imgItem = Array.from(e.clipboardData.items).find(i => i.type.startsWith("image/"));
|
||||
@@ -77,81 +164,122 @@ function NotePane({ slot, onChange }: { slot: SlotState; onChange: (s: SlotState
|
||||
const reader = new FileReader();
|
||||
reader.onload = ev => {
|
||||
const dataUrl = ev.target?.result as string;
|
||||
onChange({ ...slot, images: [...slot.images, { id: Date.now().toString(), dataUrl }] });
|
||||
const img = document.createElement("img");
|
||||
img.src = dataUrl;
|
||||
img.style.width = "60%";
|
||||
img.style.maxWidth = "100%";
|
||||
img.style.borderRadius = "6px";
|
||||
img.style.display = "block";
|
||||
img.style.margin = "6px 0";
|
||||
img.style.cursor = "pointer";
|
||||
img.draggable = false;
|
||||
const br = document.createElement("br");
|
||||
const sel = window.getSelection();
|
||||
if (sel?.rangeCount) {
|
||||
const range = sel.getRangeAt(0);
|
||||
range.collapse(false);
|
||||
range.insertNode(br);
|
||||
range.insertNode(img);
|
||||
range.setStartAfter(br);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
} else {
|
||||
ref.current?.appendChild(img);
|
||||
ref.current?.appendChild(br);
|
||||
}
|
||||
onChange(ref.current?.innerHTML ?? "");
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}, [slot, onChange]);
|
||||
}, [onChange]);
|
||||
|
||||
const handleResizeImg = (pct: number) => {
|
||||
if (!selImg) return;
|
||||
selImg.style.width = `${pct}%`;
|
||||
onChange(ref.current?.innerHTML ?? "");
|
||||
};
|
||||
|
||||
const handleDeleteImg = () => {
|
||||
if (!selImg) return;
|
||||
selImg.remove();
|
||||
setSelImg(null);
|
||||
onChange(ref.current?.innerHTML ?? "");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 h-full min-h-0">
|
||||
{showToolbar && (
|
||||
<FormatToolbar
|
||||
editorRef={ref}
|
||||
onSave={save}
|
||||
selImg={selImg}
|
||||
onResizeImg={handleResizeImg}
|
||||
onDeleteImg={handleDeleteImg}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={ref}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
onInput={save}
|
||||
onPaste={handlePaste}
|
||||
onClick={e => {
|
||||
const t = e.target as HTMLElement;
|
||||
setSelImg(t.tagName === "IMG" ? t as HTMLImageElement : null);
|
||||
}}
|
||||
className={className}
|
||||
style={{ lineHeight: 1.7, ...style }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── NotePane ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function NotePane({ slot, onChange }: { slot: SlotState; onChange: (s: SlotState) => void }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const editorCls = "flex-1 bg-slate-800/30 border border-slate-700/30 rounded-lg p-3 text-[11px] text-slate-300 outline-none focus:border-slate-600 transition-all overflow-y-auto min-h-0";
|
||||
|
||||
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 className="fixed inset-0 z-50 bg-black/80 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-4xl h-[88vh] flex flex-col shadow-2xl gap-3" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<input
|
||||
className="bg-transparent text-[13px] font-semibold text-slate-200 placeholder-slate-600 outline-none flex-1"
|
||||
placeholder="Titre / hypothèse…"
|
||||
value={slot.title}
|
||||
onChange={e => onChange({ ...slot, title: e.target.value })}
|
||||
/>
|
||||
<button onClick={() => setExpanded(false)} className="text-slate-500 hover:text-white ml-4 text-lg">✕</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…"
|
||||
<RichEditor
|
||||
html={slot.notes}
|
||||
onChange={notes => onChange({ ...slot, notes })}
|
||||
className="flex-1 bg-slate-800/40 border border-slate-700/30 rounded-xl p-4 text-[12px] text-slate-200 outline-none overflow-y-auto"
|
||||
/>
|
||||
{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">
|
||||
<div className="flex flex-col h-full gap-2 min-h-0">
|
||||
<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}
|
||||
<RichEditor
|
||||
html={slot.notes}
|
||||
onChange={notes => onChange({ ...slot, notes })}
|
||||
className={editorCls}
|
||||
style={{ minHeight: 120 }}
|
||||
/>
|
||||
{/* 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>
|
||||
>↗ Agrandir</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -265,11 +393,11 @@ function ResearchSlot({
|
||||
key={`${slot.symbol}_${slot.interval}`}
|
||||
symbol={slot.symbol}
|
||||
interval={slot.interval}
|
||||
height={380}
|
||||
height={760}
|
||||
/>
|
||||
</div>
|
||||
{/* Notes */}
|
||||
<div className="p-3 flex flex-col" style={{ minHeight: 380 }}>
|
||||
<div className="p-3 flex flex-col" style={{ minHeight: 760 }}>
|
||||
<NotePane slot={slot} onChange={onChange} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -277,61 +405,153 @@ function ResearchSlot({
|
||||
);
|
||||
}
|
||||
|
||||
// ── ArchiveCard ───────────────────────────────────────────────────────────────
|
||||
|
||||
function ArchiveCard({ a, onDelete, onRestore }: {
|
||||
a: Archive;
|
||||
onDelete: () => void;
|
||||
onRestore: (slot: 0 | 1) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const date = new Date(a.savedAt).toLocaleDateString("fr-FR", {
|
||||
day: "2-digit", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit",
|
||||
});
|
||||
const intervalLabel: Record<string, string> = {
|
||||
"1": "1m", "5": "5m", "15": "15m", "30": "30m",
|
||||
"60": "1H", "120": "2H", "240": "4H", "D": "1J", "W": "1S",
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Modal plein écran */}
|
||||
{expanded && (
|
||||
<div className="fixed inset-0 z-50 bg-black/85 flex items-center justify-center p-6" onClick={() => setExpanded(false)}>
|
||||
<div className="bg-slate-900 border border-slate-700/50 rounded-2xl w-full max-w-5xl max-h-[92vh] flex flex-col overflow-hidden shadow-2xl" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-3 px-6 py-4 border-b border-slate-700/40 shrink-0">
|
||||
<span className="text-[13px] font-bold text-slate-100 font-mono">{a.slot.symbol}</span>
|
||||
<span className="text-[9px] bg-slate-700/60 text-slate-400 rounded px-2 py-0.5">{intervalLabel[a.slot.interval] ?? a.slot.interval}</span>
|
||||
{a.slot.title && <span className="text-[12px] text-slate-300 flex-1">{a.slot.title}</span>}
|
||||
<span className="text-[9px] text-slate-600 ml-auto shrink-0">{date}</span>
|
||||
<button onClick={() => setExpanded(false)} className="text-slate-500 hover:text-white ml-3 text-lg shrink-0">✕</button>
|
||||
</div>
|
||||
<div
|
||||
className="flex-1 overflow-y-auto px-8 py-5 text-[13px] text-slate-200 prose-invert archive-content"
|
||||
dangerouslySetInnerHTML={{ __html: a.slot.notes }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Carte compacte */}
|
||||
<div className="border border-slate-700/30 rounded-xl overflow-hidden bg-slate-900/30 hover:bg-slate-800/30 transition-colors">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-slate-700/20">
|
||||
<span className="text-[10px] font-bold text-sky-400/80 font-mono">{a.slot.symbol}</span>
|
||||
<span className="text-[8px] bg-slate-700/50 text-slate-500 rounded px-1.5 py-0.5">{intervalLabel[a.slot.interval] ?? a.slot.interval}</span>
|
||||
{a.slot.title && (
|
||||
<span className="text-[10px] text-slate-300 truncate flex-1 font-medium">{a.slot.title}</span>
|
||||
)}
|
||||
<span className="text-[8px] text-slate-600 ml-auto shrink-0">{date}</span>
|
||||
{/* Restaurer */}
|
||||
<div className="relative ml-2 shrink-0">
|
||||
<button
|
||||
onClick={() => setRestoring(v => !v)}
|
||||
className="text-[8px] text-emerald-500/60 hover:text-emerald-400 transition-colors"
|
||||
title="Restaurer dans un slot actif"
|
||||
>↩ Restaurer</button>
|
||||
{restoring && (
|
||||
<div className="absolute right-0 top-5 z-20 bg-slate-800 border border-slate-700/60 rounded-lg shadow-xl p-2 flex flex-col gap-1 min-w-[120px]">
|
||||
<p className="text-[8px] text-slate-500 mb-1">Charger dans :</p>
|
||||
<button
|
||||
onClick={() => { onRestore(0); setRestoring(false); }}
|
||||
className="text-[9px] text-left px-2 py-1 rounded hover:bg-slate-700 text-slate-300 transition-colors"
|
||||
>Recherche A</button>
|
||||
<button
|
||||
onClick={() => { onRestore(1); setRestoring(false); }}
|
||||
className="text-[9px] text-left px-2 py-1 rounded hover:bg-slate-700 text-slate-300 transition-colors"
|
||||
>Recherche B</button>
|
||||
<button
|
||||
onClick={() => setRestoring(false)}
|
||||
className="text-[8px] text-slate-600 hover:text-slate-400 mt-1 text-left px-2 transition-colors"
|
||||
>Annuler</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
className="text-[8px] text-slate-600 hover:text-sky-400 transition-colors ml-1 shrink-0"
|
||||
>↗ Voir</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="text-[8px] text-red-500/30 hover:text-red-400 transition-colors ml-1 shrink-0"
|
||||
>✕</button>
|
||||
</div>
|
||||
|
||||
{/* Corps : texte + images */}
|
||||
{a.slot.notes && (
|
||||
<div className="px-4 py-3">
|
||||
{/* Texte brut (sans tags HTML) */}
|
||||
{(() => {
|
||||
const doc = new DOMParser().parseFromString(a.slot.notes, "text/html");
|
||||
const text = doc.body.textContent?.trim() ?? "";
|
||||
const imgs = Array.from(doc.images);
|
||||
return (
|
||||
<>
|
||||
{text && (
|
||||
<p className="text-[10px] text-slate-400 leading-relaxed whitespace-pre-line mb-2">{text}</p>
|
||||
)}
|
||||
{imgs.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{imgs.map((img, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={img.src}
|
||||
alt=""
|
||||
className="max-h-48 w-auto rounded-lg border border-slate-700/40 object-cover cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => setExpanded(true)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Archives panel ────────────────────────────────────────────────────────────
|
||||
|
||||
function ArchivesPanel({ archives, onDelete }: { archives: Archive[]; onDelete: (id: string) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
function ArchivesPanel({ archives, onDelete, onRestore }: {
|
||||
archives: Archive[];
|
||||
onDelete: (id: string) => void;
|
||||
onRestore: (id: string, slot: 0 | 1) => void;
|
||||
}) {
|
||||
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 className="mt-2">
|
||||
<div className="flex items-center gap-2 mb-4 px-1">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-slate-500">
|
||||
<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>
|
||||
<span className="text-[11px] font-semibold text-slate-400">Archives</span>
|
||||
<span className="text-[9px] bg-slate-700/50 text-slate-500 rounded-full px-2 py-0.5">{archives.length}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{archives.map(a => (
|
||||
<ArchiveCard
|
||||
key={a.id}
|
||||
a={a}
|
||||
onDelete={() => onDelete(a.id)}
|
||||
onRestore={slot => onRestore(a.id, slot)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -379,6 +599,17 @@ export default function IdeesTab() {
|
||||
saveLS(LS_ARCHIVES, next);
|
||||
}, [archives]);
|
||||
|
||||
const restoreArchive = useCallback((id: string, slotIdx: 0 | 1) => {
|
||||
const entry = archives.find(a => a.id === id);
|
||||
if (!entry) return;
|
||||
setSlots(prev => {
|
||||
const next = [...prev] as [SlotState, SlotState];
|
||||
next[slotIdx] = { ...entry.slot };
|
||||
saveLS(LS_SLOTS, next);
|
||||
return next;
|
||||
});
|
||||
}, [archives]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
@@ -391,7 +622,7 @@ export default function IdeesTab() {
|
||||
<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} />
|
||||
<ArchivesPanel archives={archives} onDelete={deleteArchive} onRestore={restoreArchive} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user