import React, { useState, useEffect, useMemo, useRef, useCallback, } from 'react'; import { useTranslation } from 'react-i18next'; import { useDebouncedCallback } from 'use-debounce'; import { MagnifyingGlassIcon, PlusIcon } from '@heroicons/react/24/solid'; import { PencilIcon, TrashIcon, FolderIcon, TagIcon as TagIconOutline, ClockIcon, EllipsisVerticalIcon, XMarkIcon, } from '@heroicons/react/24/outline'; import { useToast } from './Shared/ToastContext'; import { Link, useParams, useNavigate } from 'react-router-dom'; import { SortOption } from './Shared/SortFilterButton'; import NoteModal from './Note/NoteModal'; import ConfirmDialog from './Shared/ConfirmDialog'; import DiscardChangesDialog from './Shared/DiscardChangesDialog'; import MarkdownRenderer from './Shared/MarkdownRenderer'; import IconSortDropdown from './Shared/IconSortDropdown'; import TagInput from './Tag/TagInput'; import { Note } from '../entities/Note'; import { createNote, updateNote } from '../utils/notesService'; import { deleteNoteWithStoreUpdate } from '../utils/noteDeleteUtils'; import { useStore } from '../store/useStore'; import { createProject } from '../utils/projectsService'; import { ENABLE_NOTE_COLOR } from '../config/featureFlags'; const NOTE_COLORS = [ { name: 'None', value: '' }, { name: 'Red', value: '#B71C1C' }, { name: 'Orange', value: '#E65100' }, { name: 'Amber', value: '#FF8F00' }, { name: 'Green', value: '#2E7D32' }, { name: 'Teal', value: '#00695C' }, { name: 'Blue', value: '#1565C0' }, { name: 'Indigo', value: '#283593' }, { name: 'Purple', value: '#6A1B9A' }, { name: 'Pink', value: '#AD1457' }, { name: 'Grey', value: '#424242' }, ]; const shouldUseLightText = (hexColor: string | undefined): boolean => { if (!hexColor) return false; const hex = hexColor.replace('#', ''); const r = parseInt(hex.substring(0, 2), 16); const g = parseInt(hex.substring(2, 4), 16); const b = parseInt(hex.substring(4, 6), 16); const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; return luminance < 0.4; }; const Notes: React.FC = () => { const { t } = useTranslation(); const { showSuccessToast } = useToast(); const { uid } = useParams<{ uid?: string }>(); const navigate = useNavigate(); const [selectedNote, setSelectedNote] = useState(null); const [previewNote, setPreviewNote] = useState(null); const [isEditing, setIsEditing] = useState(false); const [editingNote, setEditingNote] = useState(null); const [isNoteModalOpen, setIsNoteModalOpen] = useState(false); const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); const [noteToDelete, setNoteToDelete] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [orderBy, setOrderBy] = useState('created_at:desc'); const [showProjectDropdown, setShowProjectDropdown] = useState(false); const [showTagsInput, setShowTagsInput] = useState(false); const [showDiscardDialog, setShowDiscardDialog] = useState(false); const [showNoteOptionsDropdown, setShowNoteOptionsDropdown] = useState(false); const [saveStatus, setSaveStatus] = useState< 'saved' | 'saving' | 'unsaved' >('saved'); const hasAutoSelected = useRef(false); const editingNoteColor = ENABLE_NOTE_COLOR && editingNote ? editingNote.color : undefined; const previewNoteColor = ENABLE_NOTE_COLOR && previewNote ? previewNote.color : undefined; const activeNoteColor = (isEditing && editingNoteColor) || previewNoteColor || undefined; const noteOptionsDropdownRef = useRef(null); const { notes, isLoading, isError, hasLoaded, loadNotes, setNotes } = useStore((state) => state.notesStore); const projects = useStore((state) => state.projectsStore.projects); useEffect(() => { if (!hasLoaded && !isLoading && !isError) { loadNotes(); } }, [hasLoaded, isLoading, isError, loadNotes]); const debouncedSave = useDebouncedCallback(async (noteToSave: Note) => { if (!noteToSave.title) return; try { setSaveStatus('saving'); if (noteToSave.tags && noteToSave.tags.length > 0) { const { tagsStore } = useStore.getState(); tagsStore.addNewTags(noteToSave.tags.map((t) => t.name)); } if (noteToSave.uid) { const savedNote = await updateNote(noteToSave.uid, noteToSave); const updatedNotes = notes.map((n) => n.uid === noteToSave.uid ? savedNote : n ); setNotes(updatedNotes); setEditingNote(savedNote); } else { const newNote = await createNote(noteToSave); setNotes([newNote, ...notes]); setEditingNote(newNote); navigate(`/notes/${newNote.uid}`, { replace: true }); } setSaveStatus('saved'); } catch (err) { console.error('Error autosaving note:', err); setSaveStatus('unsaved'); } }, 1000); const handleNoteChange = useCallback( (updates: Partial) => { if (!editingNote) return; const updatedNote = { ...editingNote, ...updates }; setEditingNote(updatedNote); if (updatedNote.title) { setSaveStatus('unsaved'); debouncedSave(updatedNote); } }, [editingNote, debouncedSave] ); const sortOptions: SortOption[] = [ { value: 'created_at:desc', label: t('sort.created_at', 'Created At') }, { value: 'title:asc', label: t('sort.name', 'Title') }, { value: 'updated_at:desc', label: t('common.updated', 'Updated') }, ]; const handleSortChange = (newOrderBy: string) => { setOrderBy(newOrderBy); }; const handleSelectNote = async (note: Note | null) => { if (isEditing && editingNote) { if (editingNote.title) { try { if (editingNote.tags && editingNote.tags.length > 0) { const { tagsStore } = useStore.getState(); tagsStore.addNewTags( editingNote.tags.map((t) => t.name) ); } if (editingNote.uid) { const savedNote = await updateNote( editingNote.uid, editingNote ); const updatedNotes = notes.map((n) => n.uid === editingNote.uid ? savedNote : n ); setNotes(updatedNotes); } else { const newNote = await createNote(editingNote); setNotes([newNote, ...notes]); } } catch (err) { console.error('Error saving note:', err); } } setIsEditing(false); setEditingNote(null); setShowProjectDropdown(false); setShowTagsInput(false); } setPreviewNote(note); if (note?.uid) { navigate(`/notes/${note.uid}`, { replace: true }); } else { navigate('/notes', { replace: true }); } }; const handleDeleteNote = async () => { if (!noteToDelete) return; try { await deleteNoteWithStoreUpdate(noteToDelete, showSuccessToast, t); setIsConfirmDialogOpen(false); setNoteToDelete(null); if (previewNote?.uid === noteToDelete.uid) { setPreviewNote(null); } if (editingNote?.uid === noteToDelete.uid) { setIsEditing(false); setEditingNote(null); setShowProjectDropdown(false); setShowTagsInput(false); setSaveStatus('saved'); } navigate('/notes', { replace: true }); } catch (err) { console.error('Error deleting note:', err); } }; const handleEditNote = (note: Note) => { const project = note.project || note.Project; const tags = note.tags || note.Tags || []; setEditingNote({ ...note, project_uid: project?.uid || note.project_uid, project: project, tags: tags, }); setIsEditing(true); setSaveStatus('saved'); handleSelectNote(null); }; const handleNewNote = () => { setEditingNote({ title: '', content: '', tags: [], }); setIsEditing(true); setSaveStatus('saved'); handleSelectNote(null); }; const handleSaveInlineNote = async () => { if (!editingNote || !editingNote.title) return; try { if (editingNote.tags && editingNote.tags.length > 0) { const { tagsStore } = useStore.getState(); tagsStore.addNewTags(editingNote.tags.map((t) => t.name)); } if (editingNote.uid) { const savedNote = await updateNote( editingNote.uid, editingNote ); const updatedNotes = notes.map((note) => note.uid === editingNote.uid ? savedNote : note ); setNotes(updatedNotes); setIsEditing(false); setEditingNote(null); setShowProjectDropdown(false); setShowTagsInput(false); setPreviewNote(savedNote); navigate(`/notes/${savedNote.uid}`, { replace: true }); } else { const newNote = await createNote(editingNote); setNotes([newNote, ...notes]); setIsEditing(false); setEditingNote(null); setShowProjectDropdown(false); setShowTagsInput(false); setPreviewNote(newNote); navigate(`/notes/${newNote.uid}`, { replace: true }); } } catch (err) { console.error('Error saving note:', err); } }; const handleCancelEdit = () => { setIsEditing(false); setEditingNote(null); setShowProjectDropdown(false); setShowTagsInput(false); if (previewNote) { handleSelectNote(previewNote); } }; const handleProjectButtonClick = ( e: React.MouseEvent ) => { e.preventDefault(); e.stopPropagation(); setShowProjectDropdown((prev) => !prev); setShowTagsInput(false); }; const handleTagsButtonClick = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); setShowTagsInput((prev) => !prev); setShowProjectDropdown(false); }; const handleColorChange = async (color: string, note: Note) => { if (!ENABLE_NOTE_COLOR) return; try { const updatedNote = { ...note, color }; if (previewNote?.uid === note.uid || !note.uid) { setPreviewNote(updatedNote); } if (editingNote?.uid === note.uid || (isEditing && !note.uid)) { setEditingNote(updatedNote); } if (note.uid) { const savedNote = await updateNote(note.uid, updatedNote); const updatedNotes = notes.map((n) => n.uid === note.uid ? savedNote : n ); setNotes(updatedNotes); if (previewNote?.uid === note.uid) { setPreviewNote(savedNote); } if (editingNote?.uid === note.uid) { setEditingNote(savedNote); } } setShowNoteOptionsDropdown(false); } catch (err) { console.error('Error updating note color:', err); } }; const handleSaveNote = async (noteData: Note) => { try { if (noteData.uid) { const savedNote = await updateNote(noteData.uid, noteData); const updatedNotes = notes.map((note) => note.uid === noteData.uid ? savedNote : note ); setNotes(updatedNotes); } else { const newNote = await createNote(noteData); setNotes([newNote, ...notes]); } setIsNoteModalOpen(false); setSelectedNote(null); } catch (err) { console.error('Error saving note:', err); } }; const handleCreateProject = async (name: string) => { try { const newProject = await createProject({ name, priority: 'low', }); return newProject; } catch (error) { console.error('Error creating project:', error); throw error; } }; const filteredNotes = useMemo(() => { return notes.filter( (note) => note.title.toLowerCase().includes(searchQuery.toLowerCase()) || note.content.toLowerCase().includes(searchQuery.toLowerCase()) ); }, [notes, searchQuery]); const sortedNotes = useMemo(() => { return [...filteredNotes].sort((a, b) => { const [field, direction] = orderBy.split(':'); const isAsc = direction === 'asc'; let valueA: string | number; let valueB: string | number; switch (field) { case 'title': valueA = a.title?.toLowerCase() || ''; valueB = b.title?.toLowerCase() || ''; break; case 'updated_at': valueA = a.updated_at ? new Date(a.updated_at).getTime() : 0; valueB = b.updated_at ? new Date(b.updated_at).getTime() : 0; break; case 'created_at': default: valueA = a.created_at ? new Date(a.created_at).getTime() : 0; valueB = b.created_at ? new Date(b.created_at).getTime() : 0; break; } if (valueA < valueB) return isAsc ? -1 : 1; if (valueA > valueB) return isAsc ? 1 : -1; return 0; }); }, [filteredNotes, orderBy]); useEffect(() => { if (uid && sortedNotes.length > 0 && !hasAutoSelected.current) { const noteFromUrl = sortedNotes.find((note) => note.uid === uid); if (noteFromUrl) { setPreviewNote(noteFromUrl); hasAutoSelected.current = true; } else if (!previewNote) { const isDesktop = window.innerWidth >= 768; if (isDesktop) { handleSelectNote(sortedNotes[0]); hasAutoSelected.current = true; } } } }, [uid, sortedNotes]); useEffect(() => { if ( !uid && sortedNotes.length > 0 && !previewNote && !hasAutoSelected.current ) { const isDesktop = window.innerWidth >= 768; if (isDesktop) { handleSelectNote(sortedNotes[0]); hasAutoSelected.current = true; } } }, [sortedNotes, previewNote, uid]); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( noteOptionsDropdownRef.current && !noteOptionsDropdownRef.current.contains(event.target as Node) ) { setShowNoteOptionsDropdown(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape' && isEditing) { e.preventDefault(); if (editingNote?.title) { handleSaveInlineNote(); } } }; document.addEventListener('keydown', handleEscape); return () => document.removeEventListener('keydown', handleEscape); }, [isEditing, editingNote]); if (isLoading) { return (
{t('notes.loading')}
); } if (isError) { return (
{t('notes.error')}
); } return (

{t('notes.title')}

setSearchQuery(e.target.value) } className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white text-sm" />
{sortedNotes.length === 0 ? (

{t('notes.noNotesFound')}

) : ( sortedNotes.map((note, index) => (
handleSelectNote(note)} className={`relative p-5 cursor-pointer ${ previewNote?.uid === note.uid ? 'bg-white dark:bg-gray-900 border-b border-transparent mx-4 rounded-lg' : index !== sortedNotes.length - 1 ? 'border-b border-gray-200/30 dark:border-gray-700/30 hover:bg-gray-50 dark:hover:bg-gray-800 mx-4' : 'border-b border-transparent hover:bg-gray-50 dark:hover:bg-gray-800 mx-4' }`} > {previewNote?.uid === note.uid && ( )}

{note.title || t( 'notes.untitled', 'Untitled Note' )}

{note.content.substring(0, 100)} {note.content.length > 100 ? '...' : ''}

{new Date( note.updated_at || note.created_at || '' ).toLocaleDateString()}
)) )}
{isEditing && editingNote ? (
handleNoteChange({ title: e.target.value, }) } onClick={(e) => e.stopPropagation()} placeholder="Note title..." className="w-full bg-transparent text-gray-900 dark:text-gray-100 border-none focus:outline-none focus:ring-0 pt-5 mb-4 block" style={{ color: editingNoteColor ? shouldUseLightText( editingNoteColor ) ? '#ffffff' : '#333333' : undefined, fontSize: '2rem', lineHeight: '2rem', fontWeight: 500, paddingLeft: 0, paddingRight: 0, }} autoFocus />
{editingNote.updated_at ? new Date( editingNote.updated_at ).toLocaleDateString() : 'New'}
{editingNote.title && (
{saveStatus === 'saving' && ( Saving... )} {saveStatus === 'saved' && ( ✓ Saved )} {saveStatus === 'unsaved' && ( • Unsaved changes )}
)}
{showNoteOptionsDropdown && (
{ENABLE_NOTE_COLOR && (
Background Color
{NOTE_COLORS.map( ( colorOption ) => ( ) )}
)}
{editingNote.uid && ( )}
)}
{showProjectDropdown && (
)} {showTagsInput && (
t.name)} onTagsChange={( tagNames: string[] ) => { handleNoteChange({ tags: tagNames.map( (name: string) => ({ name, }) ), }); }} availableTags={useStore .getState() .tagsStore.getTags()} />
)}