feat(Index.tsx): add functionality to navigate to notes by date using MeiliSearch
This commit is contained in:
@@ -53,12 +53,15 @@ const Index = () => {
|
|||||||
const [isInitialized, setIsInitialized] = useState(false);
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
const [fontSize, setFontSize] = useState('medium');
|
const [fontSize, setFontSize] = useState('medium');
|
||||||
const [isPreviousNoteModified, setIsPreviousNoteModified] = useState(false);
|
const [isPreviousNoteModified, setIsPreviousNoteModified] = useState(false);
|
||||||
|
const [isGotoOpen, setIsGotoOpen] = useState(false);
|
||||||
|
const [gotoDateInput, setGotoDateInput] = useState('');
|
||||||
|
|
||||||
const previousNoteRef = useRef<HTMLDivElement>(null);
|
const previousNoteRef = useRef<HTMLDivElement>(null);
|
||||||
const currentNoteRef = useRef<HTMLTextAreaElement>(null);
|
const currentNoteRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const scratchRef = useRef<HTMLTextAreaElement>(null);
|
const scratchRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const searchTimeoutRef = useRef<NodeJS.Timeout>();
|
const searchTimeoutRef = useRef<NodeJS.Timeout>();
|
||||||
|
const gotoInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Add a ref to track the last saved content
|
// Add a ref to track the last saved content
|
||||||
const lastSavedContentRef = useRef<string>('');
|
const lastSavedContentRef = useRef<string>('');
|
||||||
@@ -423,6 +426,7 @@ const Index = () => {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
q: query,
|
q: query,
|
||||||
|
matchingStrategy: 'all',
|
||||||
limit: 50,
|
limit: 50,
|
||||||
attributesToHighlight: ['content'],
|
attributesToHighlight: ['content'],
|
||||||
showRankingScore: true,
|
showRankingScore: true,
|
||||||
@@ -476,6 +480,84 @@ const Index = () => {
|
|||||||
};
|
};
|
||||||
}, [searchQuery]);
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
// Go to closest note by ISO8601 date
|
||||||
|
const goToClosestNoteToDate = async (iso8601: string) => {
|
||||||
|
try {
|
||||||
|
const target = new Date(iso8601);
|
||||||
|
const targetMs = target.getTime();
|
||||||
|
if (Number.isNaN(targetMs)) {
|
||||||
|
toast({
|
||||||
|
title: 'Invalid date',
|
||||||
|
description: 'Please enter a valid ISO8601 date.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${MEILISEARCH_API_KEY}`,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const [prevRes, nextRes] = await Promise.all([
|
||||||
|
fetch(`${MEILISEARCH_ENDPOINT}/indexes/${NOTE_INDEX}/search`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ q: '', filter: `date <= ${targetMs}`, sort: ['date:desc'], limit: 1 }),
|
||||||
|
}),
|
||||||
|
fetch(`${MEILISEARCH_ENDPOINT}/indexes/${NOTE_INDEX}/search`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ q: '', filter: `date >= ${targetMs}`, sort: ['date:asc'], limit: 1 }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [prevData, nextData] = await Promise.all([
|
||||||
|
prevRes.ok ? prevRes.json() : Promise.resolve({ hits: [] }),
|
||||||
|
nextRes.ok ? nextRes.json() : Promise.resolve({ hits: [] }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const candidates: any[] = [];
|
||||||
|
if (prevData.hits && prevData.hits.length > 0) candidates.push(prevData.hits[0]);
|
||||||
|
if (nextData.hits && nextData.hits.length > 0) candidates.push(nextData.hits[0]);
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
toast({
|
||||||
|
title: 'No notes found',
|
||||||
|
description: 'There are no notes near that date.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const closest = candidates.reduce((best, hit) => {
|
||||||
|
const bestDiff = Math.abs((best?.date ?? best?.epochTime ?? 0) - targetMs);
|
||||||
|
const hitDiff = Math.abs((hit.date ?? 0) - targetMs);
|
||||||
|
return hitDiff < bestDiff ? hit : best;
|
||||||
|
}, candidates[0]);
|
||||||
|
|
||||||
|
const targetNote: Note = {
|
||||||
|
id: closest.id,
|
||||||
|
epochTime: closest.date,
|
||||||
|
dateISO: closest.dateISO,
|
||||||
|
content: closest.content,
|
||||||
|
topLetter: closest.topLetter,
|
||||||
|
topLetterFrequency: closest.topLetterFrequency,
|
||||||
|
letterCount: closest.letterCount || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
setIsGotoOpen(false);
|
||||||
|
setGotoDateInput('');
|
||||||
|
await loadNotesAroundNote(targetNote);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error going to date:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to locate a note near that date.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Get problematic notes
|
// Get problematic notes
|
||||||
const getProblematicNotes = async () => {
|
const getProblematicNotes = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -634,6 +716,10 @@ const Index = () => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsSearchOpen(true);
|
setIsSearchOpen(true);
|
||||||
searchInputRef.current?.focus();
|
searchInputRef.current?.focus();
|
||||||
|
} else if (e.ctrlKey && (e.key === 'g' || e.key === 'G')) {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsGotoOpen(true);
|
||||||
|
setTimeout(() => gotoInputRef.current?.focus(), 0);
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Save current note if it has content
|
// Save current note if it has content
|
||||||
@@ -1155,6 +1241,45 @@ const Index = () => {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Go To Date Modal */}
|
||||||
|
<Dialog open={isGotoOpen} onOpenChange={setIsGotoOpen}>
|
||||||
|
<DialogContent className="max-w-xl bg-slate-800 border-slate-700">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className={`${getTextClass('4xl')} text-slate-100`}>Go to date</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
ref={gotoInputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter ISO8601 date (e.g. 2024-12-31T23:59:59Z)"
|
||||||
|
value={gotoDateInput}
|
||||||
|
onChange={(e) => setGotoDateInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
goToClosestNoteToDate(gotoDateInput);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`${getTextClass('2xl')} bg-slate-700 border-slate-600 text-slate-200`}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setIsGotoOpen(false)}
|
||||||
|
className={getTextClass('base')}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => goToClosestNoteToDate(gotoDateInput)}
|
||||||
|
className={getTextClass('base')}
|
||||||
|
>
|
||||||
|
Go
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Cleanup Modal */}
|
{/* Cleanup Modal */}
|
||||||
<Dialog open={isCleanupOpen} onOpenChange={setIsCleanupOpen}>
|
<Dialog open={isCleanupOpen} onOpenChange={setIsCleanupOpen}>
|
||||||
<DialogContent className="max-w-6xl max-h-[90vh] overflow-hidden bg-slate-800 border-slate-700">
|
<DialogContent className="max-w-6xl max-h-[90vh] overflow-hidden bg-slate-800 border-slate-700">
|
||||||
|
Reference in New Issue
Block a user