Publication IA: - 4 templates (magazine, brief, essay, simple) avec CSS riche - Rewrite IA (article/exercises/tutorial/reference/mixed) - Modération avec timeout 12s + fallback safe - Quotas publish_enhance par tier (basic=2, pro=15, business=100) - Détection contenu stale (hash) - Migration DB publishedContent/publishedTemplate/publishedSourceHash Fixes: - cheerio v1.2: Element -> AnyNode (domhandler), decodeEntities cast - _isShared ajouté au type Note (champ virtuel serveur) - callout colors PDF export: extraction fonction pure testable - admin/published: guard note.userId null - Cmd+S fonctionne en mode dialog (pas seulement fullPage) i18n: - 23 clés publish* traduites dans les 15 locales - Extension Web Clipper: 13 locales mise à jour Tests: - callout-colors.test.ts (6 tests) - note-visible-in-view.test.ts (5 tests) - entitlements.test.ts + byok-entitlements.test.ts: mock usageLog + unstubAllEnvs - 199/199 tests passent Tracker: user-stories.md sync avec sprint-status.yaml
612 lines
27 KiB
TypeScript
612 lines
27 KiB
TypeScript
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
|
import { motion, AnimatePresence } from 'motion/react';
|
|
import {
|
|
Search,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Plus,
|
|
Bookmark,
|
|
Layers,
|
|
FileText,
|
|
CheckCircle,
|
|
HelpCircle,
|
|
X,
|
|
CornerDownRight,
|
|
Folder,
|
|
Sliders,
|
|
Sparkles,
|
|
Command,
|
|
Settings
|
|
} from 'lucide-react';
|
|
import { Note, Carnet } from '../types';
|
|
|
|
interface SearchModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
notes: Note[];
|
|
carnets: Carnet[];
|
|
onSelectNote: (noteId: string) => void;
|
|
}
|
|
|
|
interface SearchMatch {
|
|
id: string; // Unique match identifier
|
|
noteId: string;
|
|
noteTitle: string;
|
|
path: string;
|
|
type: 'document' | 'heading' | 'paragraph' | 'list';
|
|
headingLevel?: number;
|
|
text: string;
|
|
matchedText: string;
|
|
lineIndex: number;
|
|
}
|
|
|
|
export const SearchModal: React.FC<SearchModalProps> = ({
|
|
isOpen,
|
|
onClose,
|
|
notes,
|
|
carnets,
|
|
onSelectNote
|
|
}) => {
|
|
const [query, setQuery] = useState('');
|
|
const [useRegex, setUseRegex] = useState(false);
|
|
const [caseSensitive, setCaseSensitive] = useState(false);
|
|
const [includeChildDocs, setIncludeChildDocs] = useState(true);
|
|
const [searchInTrash, setSearchInTrash] = useState(false);
|
|
const [savedQueries, setSavedQueries] = useState<string[]>(['block', 'siyuan', 'guide']);
|
|
|
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const listRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Focus input on launch
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
setTimeout(() => inputRef.current?.focus(), 50);
|
|
}
|
|
}, [isOpen]);
|
|
|
|
// Handle global keybindings in modal
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
onClose();
|
|
} else if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
setSelectedIndex(prev => Math.min(prev + 1, filteredMatches.length - 1));
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
setSelectedIndex(prev => Math.max(prev - 1, 0));
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
if (filteredMatches[selectedIndex]) {
|
|
const m = filteredMatches[selectedIndex];
|
|
onSelectNote(m.noteId);
|
|
onClose();
|
|
}
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [isOpen, selectedIndex]);
|
|
|
|
// Helper: reconstruct carnet path
|
|
const getCarnetPath = (carnetId: string): string => {
|
|
const segments: string[] = [];
|
|
let current = carnets.find(c => c.id === carnetId);
|
|
while (current) {
|
|
segments.unshift(current.name);
|
|
current = current.parentId ? carnets.find(c => c.id === current.parentId) : undefined;
|
|
}
|
|
return segments.join('/');
|
|
};
|
|
|
|
// Safe term escape for RegExp
|
|
const escapeRegExp = (string: string) => {
|
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
};
|
|
|
|
// Perform multi-match search logic across document titles and contents
|
|
const filteredMatches = useMemo(() => {
|
|
if (!query.trim()) return [];
|
|
|
|
const matches: SearchMatch[] = [];
|
|
const searchRegex = (() => {
|
|
try {
|
|
const flag = caseSensitive ? '' : 'i';
|
|
const pattern = useRegex ? query : escapeRegExp(query);
|
|
return new RegExp(pattern, flag);
|
|
} catch (e) {
|
|
return null; // Handle partial regex input gracefully
|
|
}
|
|
})();
|
|
|
|
if (!searchRegex) return [];
|
|
|
|
// Filter notes depending on trash status
|
|
const targetNotes = notes.filter(n => searchInTrash ? n.isDeleted : !n.isDeleted);
|
|
|
|
targetNotes.forEach(note => {
|
|
const notePath = getCarnetPath(note.carnetId);
|
|
const fullPath = notePath ? `${notePath}/${note.title}` : note.title;
|
|
|
|
// 1. Check Title match
|
|
if (searchRegex.test(note.title)) {
|
|
matches.push({
|
|
id: `${note.id}-title`,
|
|
noteId: note.id,
|
|
noteTitle: note.title,
|
|
path: fullPath,
|
|
type: 'document',
|
|
text: note.title,
|
|
matchedText: note.title,
|
|
lineIndex: -1
|
|
});
|
|
}
|
|
|
|
// 2. Parse Content blocks / lines
|
|
if (note.content) {
|
|
const lines = note.content.split('\n');
|
|
lines.forEach((line, index) => {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) return;
|
|
|
|
if (searchRegex.test(trimmed)) {
|
|
let type: 'heading' | 'paragraph' | 'list' = 'paragraph';
|
|
let headingLevel = undefined;
|
|
let displayVal = trimmed;
|
|
|
|
// Classify content structure elements
|
|
if (trimmed.startsWith('#')) {
|
|
type = 'heading';
|
|
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
|
|
if (headingMatch) {
|
|
headingLevel = headingMatch[1].length;
|
|
displayVal = headingMatch[2];
|
|
}
|
|
} else if (/^[-*+]\s+/.test(trimmed) || /^\d+\.\s+/.test(trimmed)) {
|
|
type = 'list';
|
|
displayVal = trimmed.replace(/^[-*+\d.]+\s+/, '');
|
|
}
|
|
|
|
matches.push({
|
|
id: `${note.id}-line-${index}`,
|
|
noteId: note.id,
|
|
noteTitle: note.title,
|
|
path: fullPath,
|
|
type,
|
|
headingLevel,
|
|
text: trimmed,
|
|
matchedText: displayVal,
|
|
lineIndex: index
|
|
});
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
return matches;
|
|
}, [notes, query, useRegex, caseSensitive, searchInTrash, carnets]);
|
|
|
|
// Ensure index remains in bounds when matches array updates
|
|
useEffect(() => {
|
|
setSelectedIndex(0);
|
|
}, [query]);
|
|
|
|
// Toggle saving criteria
|
|
const handleSaveCriteria = () => {
|
|
if (query.trim() && !savedQueries.includes(query.trim())) {
|
|
setSavedQueries(prev => [...prev, query.trim()]);
|
|
}
|
|
};
|
|
|
|
const handleRemoveCriteria = () => {
|
|
setSavedQueries(prev => prev.filter(q => q !== query.trim()));
|
|
};
|
|
|
|
// Count distinct notes involved in match list
|
|
const docMatchesCount = useMemo(() => {
|
|
const uniqueNoteIds = new Set(filteredMatches.map(m => m.noteId));
|
|
return uniqueNoteIds.size;
|
|
}, [filteredMatches]);
|
|
|
|
const activeMatch = filteredMatches[selectedIndex];
|
|
|
|
// Dynamically load document content with visual query highlights
|
|
const highlightedNotePreviewContent = useMemo(() => {
|
|
if (!activeMatch) return null;
|
|
const currentNote = notes.find(n => n.id === activeMatch.noteId);
|
|
if (!currentNote) return null;
|
|
|
|
if (!query.trim()) return currentNote.content;
|
|
|
|
try {
|
|
const flag = caseSensitive ? 'g' : 'gi';
|
|
const searchPattern = useRegex ? query : escapeRegExp(query);
|
|
const highlightRegex = new RegExp(`(${searchPattern})`, flag);
|
|
|
|
// Return content split by line to let us format block matches neatly
|
|
const lines = (currentNote.content || '').split('\n');
|
|
|
|
// Let's frame the match around the matched line for contextual proximity
|
|
const targetIndex = activeMatch.lineIndex >= 0 ? activeMatch.lineIndex : 0;
|
|
const startLine = Math.max(0, targetIndex - 3);
|
|
const endLine = Math.min(lines.length - 1, targetIndex + 5);
|
|
|
|
return (
|
|
<div className="space-y-1 my-2">
|
|
{startLine > 0 && (
|
|
<div className="text-[10px] text-concrete/40 italic pl-4">...</div>
|
|
)}
|
|
{lines.slice(startLine, endLine + 1).map((line, idx) => {
|
|
const absoluteIdx = startLine + idx;
|
|
const isMatchLine = absoluteIdx === targetIndex;
|
|
const hasMatches = highlightRegex.test(line);
|
|
|
|
// Reconstruct highlighted segments
|
|
const segments = line.split(highlightRegex);
|
|
|
|
return (
|
|
<div
|
|
key={absoluteIdx}
|
|
className={`py-1 px-3 rounded-lg text-xs leading-relaxed flex items-start gap-4 transition-colors
|
|
${isMatchLine ? 'bg-amber-100/15 border-l-2 border-amber-500 pl-2.5 dark:bg-amber-500/5' : 'opacity-85'}`}
|
|
>
|
|
<span className="font-mono text-[9px] text-concrete/40 text-right w-6 select-none mt-1">
|
|
{absoluteIdx + 1}
|
|
</span>
|
|
|
|
<span className="font-sans text-ink dark:text-dark-ink break-all">
|
|
{hasMatches ? (
|
|
segments.map((seg, sIdx) => {
|
|
const matchesPattern = highlightRegex.test(seg);
|
|
return matchesPattern ? (
|
|
<mark
|
|
key={sIdx}
|
|
className="bg-amber-500/30 text-ink dark:text-white dark:bg-amber-400/40 rounded px-0.5 border-b border-amber-600 font-semibold"
|
|
>
|
|
{seg}
|
|
</mark>
|
|
) : (
|
|
seg
|
|
);
|
|
})
|
|
) : (
|
|
line
|
|
)}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
{endLine < lines.length - 1 && (
|
|
<div className="text-[10px] text-concrete/40 italic pl-4">...</div>
|
|
)}
|
|
</div>
|
|
);
|
|
} catch (e) {
|
|
return <div className="text-xs text-concrete pr-4">{currentNote.content}</div>;
|
|
}
|
|
}, [activeMatch, notes, query, useRegex, caseSensitive]);
|
|
|
|
// Render text segment highlight in results row items
|
|
const renderHighlightedRowText = (text: string) => {
|
|
if (!query.trim()) return text;
|
|
try {
|
|
const flag = caseSensitive ? 'gi' : 'gi';
|
|
const searchPattern = useRegex ? query : escapeRegExp(query);
|
|
const highlightRegex = new RegExp(`(${searchPattern})`, flag);
|
|
const segments = text.split(highlightRegex);
|
|
|
|
return (
|
|
<span className="truncate">
|
|
{segments.map((seg, sIdx) => {
|
|
const isMatch = highlightRegex.test(seg);
|
|
return isMatch ? (
|
|
<mark key={sIdx} className="bg-amber-400/35 text-ink dark:text-white dark:bg-amber-500/45 px-0.5 rounded font-black">
|
|
{seg}
|
|
</mark>
|
|
) : (
|
|
seg
|
|
);
|
|
})}
|
|
</span>
|
|
);
|
|
} catch (e) {
|
|
return text;
|
|
}
|
|
};
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/40 dark:bg-black/60 backdrop-blur-xs flex items-center justify-center z-[100] p-4 sm:p-6 select-none font-sans">
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.98, y: 10 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.98, y: 10 }}
|
|
className="w-full max-w-[840px] h-[580px] sm:h-[640px] rounded-2xl bg-white dark:bg-[#121212] border border-border dark:border-zinc-800 shadow-2xl flex flex-col overflow-hidden"
|
|
>
|
|
{/* TOP Advanced Search Bar Row */}
|
|
<div className="p-4 border-b border-border/60 dark:border-zinc-800/80 bg-paper/50 dark:bg-[#161616] flex flex-col gap-3 shrink-0">
|
|
<div className="flex items-center gap-2.5 relative">
|
|
<Search size={18} className="text-concrete absolute left-3 top-1/2 -translate-y-1/2 shrink-0" />
|
|
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder="Rechercher des documents ou des blocs de texte..."
|
|
className="w-full text-sm pl-10 pr-24 py-2.5 rounded-xl border border-border/70 dark:border-zinc-800/80 bg-white/85 dark:bg-[#1C1C1C] text-ink dark:text-dark-ink placeholder-concrete/50 outline-none focus:border-accent"
|
|
/>
|
|
|
|
{/* Config Quick Badges */}
|
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1.5 bg-paper dark:bg-transparent rounded-lg p-0.5">
|
|
<button
|
|
onClick={() => setCaseSensitive(!caseSensitive)}
|
|
className={`px-1.5 py-1 text-[9.5px] font-bold rounded-md hover:bg-black/5 dark:hover:bg-white/5 uppercase select-none transition-colors
|
|
${caseSensitive ? 'text-accent bg-accent/5' : 'text-concrete'}`}
|
|
title="Respecter la casse (Aa)"
|
|
>
|
|
Aa
|
|
</button>
|
|
<button
|
|
onClick={() => setUseRegex(!useRegex)}
|
|
className={`px-1.5 py-1 text-[9.5px] font-bold rounded-md hover:bg-black/5 dark:hover:bg-white/5 uppercase select-none transition-colors
|
|
${useRegex ? 'text-accent bg-accent/5' : 'text-concrete'}`}
|
|
title="Activer Regex (.*)"
|
|
>
|
|
.*
|
|
</button>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-1 hover:bg-black/5 dark:hover:bg-white/10 rounded-md text-concrete transition-all"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quick saved criteria filter tags */}
|
|
{savedQueries.length > 0 && (
|
|
<div className="flex items-center gap-2 text-[10px] text-concrete font-bold tracking-tight">
|
|
<span className="uppercase text-[9px]">Favoris:</span>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{savedQueries.map(sq => (
|
|
<button
|
|
key={sq}
|
|
onClick={() => setQuery(sq)}
|
|
className={`px-2 py-0.5 rounded-md border text-[9.5px] font-medium transition-all hover:border-accent
|
|
${query === sq
|
|
? 'bg-accent/10 border-accent text-accent'
|
|
: 'bg-white dark:bg-zinc-800 border-border/40 text-muted-ink'}`}
|
|
>
|
|
{sq}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* UTILITY BAR Row -> Match statistics with action links */}
|
|
<div className="px-4 py-2 bg-[#F8F7F4] dark:bg-[#141414] border-b border-border/40 dark:border-zinc-850 flex items-center justify-between shrink-0">
|
|
<div className="flex items-center gap-3">
|
|
{/* Arrow Switchers */}
|
|
<div className="flex items-center gap-1 border border-border/40 dark:border-zinc-800 bg-white dark:bg-zinc-900 rounded-lg p-0.5">
|
|
<button
|
|
disabled={filteredMatches.length === 0}
|
|
onClick={() => setSelectedIndex(prev => Math.max(0, prev - 1))}
|
|
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-concrete disabled:opacity-40"
|
|
>
|
|
<ChevronLeft size={12} />
|
|
</button>
|
|
<span className="text-[9.5px] font-bold font-mono px-1.5 text-concrete">
|
|
{filteredMatches.length > 0 ? `${selectedIndex + 1}/${filteredMatches.length}` : '0/0'}
|
|
</span>
|
|
<button
|
|
disabled={filteredMatches.length === 0}
|
|
onClick={() => setSelectedIndex(prev => Math.min(filteredMatches.length - 1, prev + 1))}
|
|
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-concrete disabled:opacity-40"
|
|
>
|
|
<ChevronRight size={12} />
|
|
</button>
|
|
</div>
|
|
|
|
<span className="text-[11px] font-medium text-concrete">
|
|
{filteredMatches.length > 0
|
|
? `Trouvé ${filteredMatches.length} occurrences dans ${docMatchesCount} documents`
|
|
: query.trim() ? "Aucun élément ne correspond" : "Saisissez votre requête"}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Toolbar Action Links */}
|
|
<div className="flex items-center gap-4">
|
|
{query.trim() && (
|
|
<button
|
|
onClick={savedQueries.includes(query.trim()) ? handleRemoveCriteria : handleSaveCriteria}
|
|
className="text-[10px] font-bold uppercase tracking-wider text-accent border-b border-dashed border-accent hover:border-solid select-none"
|
|
>
|
|
{savedQueries.includes(query.trim()) ? 'Supprimer favori' : 'Sauvegarder recherche'}
|
|
</button>
|
|
)}
|
|
|
|
<label className="flex items-center gap-1.5 cursor-pointer text-[10.5px] font-medium text-concrete">
|
|
<input
|
|
type="checkbox"
|
|
checked={includeChildDocs}
|
|
onChange={(e) => setIncludeChildDocs(e.target.checked)}
|
|
className="rounded border-border/60 text-accent focus:ring-accent w-3 h-3"
|
|
/>
|
|
<span>Sous-docs inclus</span>
|
|
</label>
|
|
|
|
<label className="flex items-center gap-1.5 cursor-pointer text-[10.5px] font-medium text-concrete">
|
|
<input
|
|
type="checkbox"
|
|
checked={searchInTrash}
|
|
onChange={(e) => setSearchInTrash(e.target.checked)}
|
|
className="rounded border-border/60 text-accent focus:ring-accent w-3 h-3"
|
|
/>
|
|
<span>Corbeille incluse</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* DUAL SECTION LAYOUT */}
|
|
<div className="flex-1 flex overflow-hidden">
|
|
|
|
{/* Left Section: Scrollable matches list */}
|
|
<div className="w-[45%] h-full border-r border-border/40 dark:border-zinc-800 flex flex-col bg-[#FAF9F5]/30 dark:bg-[#121212]/30 overflow-hidden">
|
|
<div ref={listRef} className="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1">
|
|
{filteredMatches.map((m, idx) => {
|
|
const isSelected = idx === selectedIndex;
|
|
return (
|
|
<div
|
|
key={m.id}
|
|
onClick={() => setSelectedIndex(idx)}
|
|
onDoubleClick={() => {
|
|
onSelectNote(m.noteId);
|
|
onClose();
|
|
}}
|
|
className={`p-2.5 rounded-xl cursor-pointer text-left select-none relative group/item transition-all flex flex-col gap-1 border
|
|
${isSelected
|
|
? 'bg-white dark:bg-zinc-800 shadow-md border-amber-500/30'
|
|
: 'border-transparent hover:bg-black/[0.02] dark:hover:bg-white/[0.02]/30'}`}
|
|
>
|
|
{/* Selection overlay accent */}
|
|
{isSelected && (
|
|
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-3.5 bg-amber-500 rounded-r-full" />
|
|
)}
|
|
|
|
<div className="flex items-center justify-between text-[11px] gap-2">
|
|
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
|
{/* Element classifier badges */}
|
|
{m.type === 'document' && (
|
|
<FileText size={12} className="text-sky-500 shrink-0" />
|
|
)}
|
|
{m.type === 'heading' && (
|
|
<span className="text-[8.5px] font-extrabold uppercase bg-indigo-50 dark:bg-indigo-950/40 text-indigo-500 border border-indigo-500/10 px-1 rounded-sm shrink-0 font-mono">
|
|
H{m.headingLevel || ''}
|
|
</span>
|
|
)}
|
|
{m.type === 'list' && (
|
|
<span className="text-[8.5px] font-extrabold uppercase bg-emerald-50 dark:bg-emerald-950/40 text-emerald-500 border border-emerald-500/10 px-1 rounded-sm shrink-0 font-mono">
|
|
LIST
|
|
</span>
|
|
)}
|
|
{m.type === 'paragraph' && (
|
|
<span className="text-[8px] font-extrabold uppercase bg-zinc-100 dark:bg-zinc-800 text-concrete border border-border/20 px-1 rounded-sm shrink-0 font-mono">
|
|
TXT
|
|
</span>
|
|
)}
|
|
|
|
<span className={`font-semibold truncate leading-none text-xs ${isSelected ? 'text-ink dark:text-dark-ink' : 'text-muted-ink'}`}>
|
|
{m.noteTitle}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Highlighted snippet row content */}
|
|
<div className="text-[11px] text-concrete truncate pl-4.5 font-sans leading-tight">
|
|
{renderHighlightedRowText(m.matchedText)}
|
|
</div>
|
|
|
|
{/* Breadcrumb row path */}
|
|
<div className="text-[8.5px] font-mono tracking-widest uppercase text-concrete/45 truncate pl-4.5 mt-0.5 max-w-full">
|
|
{m.path}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{filteredMatches.length === 0 && (
|
|
<div className="h-full flex flex-col items-center justify-center text-center p-6 text-concrete pt-32 space-y-2">
|
|
<Search size={22} className="opacity-35 text-concrete animate-pulse" />
|
|
<p className="text-[11px] font-medium italic opacity-70">
|
|
{query.trim() ? "Aucun bloc ou doc ne correspond à cette recherche." : "Taper pour obtenir des résultats instantanés."}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Section: Scrollable content preview card with visual highlighted markers */}
|
|
<div className="flex-1 h-full bg-[#FCFCFA]/80 dark:bg-[#151515] flex flex-col overflow-hidden">
|
|
{activeMatch ? (
|
|
<div className="flex-1 flex flex-col p-5 overflow-hidden justify-between">
|
|
<div className="space-y-4 overflow-hidden flex flex-col flex-1">
|
|
{/* Breadcrumb locator line */}
|
|
<div className="flex items-center gap-1.5 p-2 bg-black/[0.02] dark:bg-white/[0.02] border border-border/40 rounded-xl">
|
|
<Folder size={11} className="text-concrete" />
|
|
<span className="text-[9.5px] font-mono tracking-widest text-concrete font-medium uppercase truncate flex-1">
|
|
{activeMatch.path}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Document focus heading title */}
|
|
<div className="border-b border-border/40 dark:border-zinc-800 pb-2">
|
|
<h4 className="text-[13px] font-serif font-black text-ink dark:text-dark-ink">
|
|
{activeMatch.noteTitle}
|
|
</h4>
|
|
<p className="text-[8px] uppercase tracking-wider text-concrete font-bold mt-1">APERÇU CONTEXTUEL DU BLOC</p>
|
|
</div>
|
|
|
|
{/* Dynamic document contents highlighted and framed */}
|
|
<div className="flex-1 overflow-y-auto custom-scrollbar pr-1 bg-white dark:bg-[#121212] border border-border/30 rounded-xl p-3.5 shadow-inner">
|
|
{highlightedNotePreviewContent}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quick Actions trigger buttons */}
|
|
<div className="pt-4 border-t border-border/40 dark:border-zinc-800 flex items-center justify-between shrink-0">
|
|
<button
|
|
onClick={() => {
|
|
onSelectNote(activeMatch.noteId);
|
|
onClose();
|
|
}}
|
|
className="px-5 py-2.5 bg-ink text-white dark:bg-white dark:text-black hover:scale-102 active:scale-98 text-xs font-semibold rounded-xl flex items-center gap-2 transition-all shadow-sm"
|
|
>
|
|
<CornerDownRight size={13} />
|
|
<span>Ouvrir dans l'éditeur</span>
|
|
</button>
|
|
<span className="text-[10px] text-concrete font-bold font-mono bg-paper dark:bg-white/5 border border-border/30 px-2 py-1 rounded">
|
|
ID: {activeMatch.noteId.slice(0, 6)}...
|
|
</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex-1 flex flex-col items-center justify-center text-center p-6 text-concrete space-y-3">
|
|
<HelpCircle size={24} className="opacity-25" />
|
|
<div className="space-y-1">
|
|
<p className="text-[11.5px] font-bold">Aperçu du document</p>
|
|
<p className="text-[10px] italic opacity-60">Sélectionnez un résultat de recherche de la colonne et explorez immédiatement son contenu sémantique.</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{/* BOTTOM Status Keyboard shortcuts hint footer bar */}
|
|
<div className="p-3.5 bg-[#FAF9F5] dark:bg-[#0E0E0E] border-t border-border/50 dark:border-zinc-800/60 flex items-center justify-between shrink-0 font-sans">
|
|
<div className="flex items-center gap-5 text-[9.5px] font-bold text-concrete/75 antialiased">
|
|
<span className="flex items-center gap-1.5"><strong className="bg-slate-200 dark:bg-zinc-800 px-1 py-0.5 rounded text-ink dark:text-light">↑↓</strong> naviguer</span>
|
|
<span className="flex items-center gap-1.5"><strong className="bg-slate-200 dark:bg-zinc-800 px-1 py-0.5 rounded text-ink dark:text-light">Entrée</strong> ouvrir</span>
|
|
<span className="flex items-center gap-1.5"><strong className="bg-slate-200 dark:bg-zinc-800 px-1 py-0.5 rounded text-ink dark:text-light">Double clic</strong> ouvrir</span>
|
|
<span className="flex items-center gap-1.5"><strong className="bg-slate-200 dark:bg-zinc-800 px-1 py-0.5 rounded text-ink dark:text-light">Échap</strong> fermer</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1.5 text-[9px] font-bold uppercase tracking-wider text-concrete/60">
|
|
<Command size={10} />
|
|
<span>Memento Search OS v2.3</span>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
);
|
|
};
|