Merge t3code/expand-inspector-editor: add expandable full-screen editor modal to ResearchInspector
This commit is contained in:
367
MosaicIQ/src/components/Research/ResearchInspector.tsx
Normal file
367
MosaicIQ/src/components/Research/ResearchInspector.tsx
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { Pin, Archive, RefreshCw, Sparkles, Maximize2, Minimize2 } from 'lucide-react';
|
||||||
|
import type {
|
||||||
|
GhostNote,
|
||||||
|
NoteAuditTrail,
|
||||||
|
NoteType,
|
||||||
|
ResearchNote,
|
||||||
|
ThesisStatus,
|
||||||
|
} from '../../types/research';
|
||||||
|
import { ConfidenceBadge, EvidenceBadge, FreshnessBadge, NoteTypeBadge, TagChip } from './primitives/Badges';
|
||||||
|
import { GHOST_CLASS_LABELS, NOTE_TYPE_LABELS } from './primitives/researchMeta';
|
||||||
|
|
||||||
|
interface ResearchInspectorProps {
|
||||||
|
note: ResearchNote | null;
|
||||||
|
ghost: GhostNote | null;
|
||||||
|
auditTrail: NoteAuditTrail | null;
|
||||||
|
isLoadingAuditTrail: boolean;
|
||||||
|
onRefreshAuditTrail: () => void;
|
||||||
|
onUpdateNote: (noteId: string, patch: {
|
||||||
|
rawText?: string;
|
||||||
|
title?: string;
|
||||||
|
noteType?: NoteType;
|
||||||
|
pinned?: boolean;
|
||||||
|
thesisStatus?: ThesisStatus;
|
||||||
|
}) => Promise<unknown>;
|
||||||
|
onArchiveNote: (noteId: string, archived: boolean) => Promise<unknown>;
|
||||||
|
onPromoteNote: (noteId: string, thesisStatus?: ThesisStatus, noteType?: NoteType) => Promise<unknown>;
|
||||||
|
onReviewGhost: (ghostId: string, action: 'accept' | 'ignore' | 'dismiss' | 'pin') => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const editableNoteTypes: NoteType[] = [
|
||||||
|
'fact',
|
||||||
|
'management_signal',
|
||||||
|
'claim',
|
||||||
|
'thesis',
|
||||||
|
'sub_thesis',
|
||||||
|
'risk',
|
||||||
|
'catalyst',
|
||||||
|
'valuation_point',
|
||||||
|
'question',
|
||||||
|
'contradiction',
|
||||||
|
'channel_check',
|
||||||
|
'mosaic_insight',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ResearchInspector: React.FC<ResearchInspectorProps> = ({
|
||||||
|
note,
|
||||||
|
ghost,
|
||||||
|
auditTrail,
|
||||||
|
isLoadingAuditTrail,
|
||||||
|
onRefreshAuditTrail,
|
||||||
|
onUpdateNote,
|
||||||
|
onArchiveNote,
|
||||||
|
onPromoteNote,
|
||||||
|
onReviewGhost,
|
||||||
|
}) => {
|
||||||
|
const [draftTitle, setDraftTitle] = useState('');
|
||||||
|
const [draftBody, setDraftBody] = useState('');
|
||||||
|
const [draftType, setDraftType] = useState<NoteType>('claim');
|
||||||
|
const [isEditorExpanded, setIsEditorExpanded] = useState(false);
|
||||||
|
const expandedEditorRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!note) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDraftTitle(note.title ?? '');
|
||||||
|
setDraftBody(note.rawText);
|
||||||
|
setDraftType(note.noteType);
|
||||||
|
}, [note]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditorExpanded && expandedEditorRef.current) {
|
||||||
|
expandedEditorRef.current.focus();
|
||||||
|
expandedEditorRef.current.setSelectionRange(
|
||||||
|
expandedEditorRef.current.value.length,
|
||||||
|
expandedEditorRef.current.value.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [isEditorExpanded]);
|
||||||
|
|
||||||
|
const handleExpandKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setIsEditorExpanded(false);
|
||||||
|
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsEditorExpanded(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!note && !ghost) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col justify-between p-4 sm:p-5">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-[var(--research-text-strong)] sm:text-lg">
|
||||||
|
Inspector
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-xs leading-6 text-[var(--research-text-muted)] sm:mt-2.5 sm:text-sm">
|
||||||
|
Select a note or ghost note to inspect provenance, links, evidence, and promotion controls.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ghost) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 p-4 sm:space-y-5 sm:p-5">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-[0.2em] text-term-text-tertiary">Ghost Note</div>
|
||||||
|
<h2 className="mt-2 text-lg font-semibold text-term-text sm:text-xl">
|
||||||
|
{ghost.headline}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2.5 text-sm leading-6 text-term-text-muted sm:mt-3 sm:leading-7">
|
||||||
|
{ghost.body}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<TagChip label={GHOST_CLASS_LABELS[ghost.ghostClass]} />
|
||||||
|
<ConfidenceBadge confidence={ghost.confidence} />
|
||||||
|
<FreshnessBadge timestamp={ghost.updatedAt} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2.5 sm:gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void onReviewGhost(ghost.id, 'accept');
|
||||||
|
}}
|
||||||
|
className="w-full rounded-2xl border border-info/40 bg-info/10 px-4 py-2.5 text-sm text-info transition-colors hover:border-info hover:text-term-text sm:py-3"
|
||||||
|
>
|
||||||
|
Accept ghost
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void onReviewGhost(ghost.id, 'pin');
|
||||||
|
}}
|
||||||
|
className="w-full rounded-2xl border border-term-border bg-term-surface px-4 py-2.5 text-sm text-term-text transition-colors hover:border-info/50 sm:py-3"
|
||||||
|
>
|
||||||
|
Pin ghost
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void onReviewGhost(ghost.id, 'dismiss');
|
||||||
|
}}
|
||||||
|
className="w-full rounded-2xl border border-negative/40 bg-negative/10 px-4 py-2.5 text-sm text-negative transition-colors hover:border-negative/70 sm:py-3"
|
||||||
|
>
|
||||||
|
Dismiss ghost
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 p-4 sm:space-y-5 sm:p-5">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<NoteTypeBadge noteType={note!.noteType} />
|
||||||
|
<EvidenceBadge status={note!.evidenceStatus} />
|
||||||
|
<ConfidenceBadge confidence={note!.confidence} />
|
||||||
|
<FreshnessBadge timestamp={note!.updatedAt} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex flex-col gap-2">
|
||||||
|
<span className="text-[10px] uppercase tracking-[0.18em] text-term-text-tertiary">Title</span>
|
||||||
|
<input
|
||||||
|
value={draftTitle}
|
||||||
|
onChange={(event) => setDraftTitle(event.target.value)}
|
||||||
|
className="w-full rounded-2xl border border-term-border bg-term-bg px-3 py-2.5 text-sm text-term-text outline-none transition-colors focus:border-info sm:py-3"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[10px] uppercase tracking-[0.18em] text-term-text-tertiary">Body</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsEditorExpanded(true)}
|
||||||
|
className="inline-flex items-center gap-1 rounded-lg border border-term-border bg-term-surface px-1.5 py-1 text-term-text-tertiary transition-colors hover:border-info/60 hover:text-term-text"
|
||||||
|
aria-label="Expand editor"
|
||||||
|
>
|
||||||
|
<Maximize2 className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={draftBody}
|
||||||
|
onChange={(event) => setDraftBody(event.target.value)}
|
||||||
|
className="min-h-[160px] w-full rounded-2xl border border-term-border bg-term-bg px-3 py-2.5 text-sm leading-6 text-term-text outline-none transition-colors focus:border-info sm:min-h-40 sm:py-3"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-2">
|
||||||
|
<span className="text-[10px] uppercase tracking-[0.18em] text-term-text-tertiary">Note Type</span>
|
||||||
|
<select
|
||||||
|
value={draftType}
|
||||||
|
onChange={(event) => setDraftType(event.target.value as NoteType)}
|
||||||
|
className="w-full rounded-2xl border border-term-border bg-term-bg px-3 py-2.5 text-sm text-term-text outline-none transition-colors focus:border-info sm:py-3"
|
||||||
|
>
|
||||||
|
{editableNoteTypes.map((noteType) => (
|
||||||
|
<option key={noteType} value={noteType}>
|
||||||
|
{NOTE_TYPE_LABELS[noteType]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2.5 sm:gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void onUpdateNote(note!.id, {
|
||||||
|
title: draftTitle,
|
||||||
|
rawText: draftBody,
|
||||||
|
noteType: draftType,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="rounded-2xl border border-info/40 bg-info/10 px-3 py-2.5 text-sm text-info transition-colors hover:border-info hover:text-term-text sm:px-4 sm:py-3"
|
||||||
|
>
|
||||||
|
Save note
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void onPromoteNote(note!.id, 'accepted_core', note!.noteType === 'thesis' ? 'thesis' : 'sub_thesis');
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center justify-center gap-1.5 rounded-2xl border border-positive/35 bg-positive/10 px-3 py-2.5 text-sm text-positive transition-colors hover:border-positive/60 sm:gap-2 sm:px-4 sm:py-3"
|
||||||
|
>
|
||||||
|
<Sparkles className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||||
|
<span className="hidden xs:inline">Promote</span>
|
||||||
|
<span className="xs:hidden">Promote</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2.5 sm:gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void onUpdateNote(note!.id, { pinned: !note!.pinned });
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center justify-center gap-1.5 rounded-2xl border border-term-border bg-term-surface px-3 py-2.5 text-sm text-term-text transition-colors hover:border-info/50 sm:gap-2 sm:px-4 sm:py-3"
|
||||||
|
>
|
||||||
|
<Pin className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||||
|
<span className="hidden xs:inline">{note!.pinned ? 'Unpin' : 'Pin'}</span>
|
||||||
|
<span className="xs:hidden">{note!.pinned ? 'Unpin' : 'Pin'}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void onArchiveNote(note!.id, !note!.archived);
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center justify-center gap-1.5 rounded-2xl border border-negative/40 bg-negative/10 px-3 py-2.5 text-sm text-negative transition-colors hover:border-negative/70 sm:gap-2 sm:px-4 sm:py-3"
|
||||||
|
>
|
||||||
|
<Archive className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||||
|
<span className="hidden xs:inline">{note!.archived ? 'Restore' : 'Archive'}</span>
|
||||||
|
<span className="xs:hidden">{note!.archived ? 'Restore' : 'Archive'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 rounded-2xl border border-term-border bg-term-surface p-3 sm:p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-[10px] uppercase tracking-[0.18em] text-term-text-tertiary">Audit Trail</div>
|
||||||
|
<div className="mt-1 truncate text-sm text-term-text">
|
||||||
|
{isLoadingAuditTrail ? 'Loading evidence trace...' : `${auditTrail?.links.length ?? 0} links, ${auditTrail?.sources.length ?? 0} sources`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRefreshAuditTrail}
|
||||||
|
className="inline-flex shrink-0 items-center gap-1.5 rounded-xl border border-term-border bg-term-bg px-2.5 py-2 text-xs uppercase tracking-[0.18em] text-term-text-tertiary transition-colors hover:border-info/60 hover:text-term-text sm:gap-2 sm:px-3"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
<span className="hidden sm:inline">Refresh</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{auditTrail ? (
|
||||||
|
<div className="space-y-3 text-sm text-term-text-muted">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-[0.18em] text-term-text-tertiary">Sources</div>
|
||||||
|
<ul className="mt-2 space-y-2">
|
||||||
|
{auditTrail.sources.slice(0, 4).map((source) => (
|
||||||
|
<li key={source.id} className="truncate text-xs sm:text-sm">
|
||||||
|
{source.title}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-[0.18em] text-term-text-tertiary">Audit Events</div>
|
||||||
|
<ul className="mt-2 space-y-2">
|
||||||
|
{auditTrail.auditEvents.slice(0, 5).map((event) => (
|
||||||
|
<li key={event.id} className="text-xs sm:text-sm">
|
||||||
|
<span className="text-term-text">{event.action}</span>
|
||||||
|
<span className="text-term-text-tertiary"> • {event.createdAt}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-term-text-tertiary">Select a note to inspect its evidence chain.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditorExpanded && note && createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
|
onClick={() => setIsEditorExpanded(false)}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Expanded note editor"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex max-h-[90vh] w-full max-w-4xl flex-col overflow-hidden rounded-[24px] border border-term-border bg-term-elevated shadow-2xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={handleExpandKeyDown}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-term-border bg-term-surface px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-mono font-semibold text-term-text">Edit Note</h2>
|
||||||
|
<p className="mt-1 text-xs text-term-text-muted">{draftTitle || 'Untitled'}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsEditorExpanded(false)}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-xl border border-term-border bg-term-bg px-2.5 py-2 text-term-text-tertiary transition-colors hover:border-info/60 hover:text-term-text"
|
||||||
|
aria-label="Collapse editor"
|
||||||
|
>
|
||||||
|
<Minimize2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
<textarea
|
||||||
|
ref={expandedEditorRef}
|
||||||
|
value={draftBody}
|
||||||
|
onChange={(e) => setDraftBody(e.target.value)}
|
||||||
|
className="min-h-[50vh] w-full resize-y rounded-2xl border border-term-border bg-term-bg px-4 py-3 text-sm leading-7 text-term-text outline-none transition-colors focus:border-info"
|
||||||
|
placeholder="Write your note..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-t border-term-border bg-term-surface px-6 py-4">
|
||||||
|
<p className="text-[10px] font-mono text-term-text-tertiary">
|
||||||
|
Press <kbd className="rounded border border-term-border bg-term-bg px-1 py-0.5">Esc</kbd> or{' '}
|
||||||
|
<kbd className="rounded border border-term-border bg-term-bg px-1 py-0.5">⌘ Enter</kbd> to collapse
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsEditorExpanded(false)}
|
||||||
|
className="rounded border border-info bg-info px-4 py-2 text-xs font-mono font-semibold text-term-bg transition-colors hover:brightness-110"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user