import React, { useState, useEffect, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { MagnifyingGlassIcon, PlusIcon } from '@heroicons/react/24/solid'; import { PencilIcon, TrashIcon, FolderIcon, TagIcon as TagIconOutline, FunnelIcon, 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 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'; // Define colors for note backgrounds 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' }, ]; // Helper function to determine if we should use light text on a dark background const shouldUseLightText = (hexColor: string | undefined): boolean => { if (!hexColor) return false; // Convert hex to RGB const hex = hexColor.replace('#', ''); const r = parseInt(hex.substr(0, 2), 16); const g = parseInt(hex.substr(2, 2), 16); const b = parseInt(hex.substr(4, 2), 16); // Calculate relative luminance const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; // Use light text if luminance is below 0.4 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 [showSortDropdown, setShowSortDropdown] = useState(false); const [showNoteOptionsDropdown, setShowNoteOptionsDropdown] = useState(false); 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 sortDropdownRef = useRef(null); const noteOptionsDropdownRef = useRef(null); // Get notes and projects from global store 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]); // Projects are now loaded by Layout component into global store // Sort options for notes 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') }, ]; // Handle sort change const handleSortChange = (newOrderBy: string) => { setOrderBy(newOrderBy); }; // Function to set preview note and update URL const handleSelectNote = async (note: Note | null) => { // If we're editing a note, save it first if (isEditing && editingNote) { // If the note has a title, save it if (editingNote.title) { try { // Add new tags to store if they don't exist 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); } } // Exit edit mode 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 we deleted the currently viewed note, clear the URL if (previewNote?.uid === noteToDelete.uid) { navigate('/notes', { replace: true }); } } catch (err) { console.error('Error deleting note:', err); } }; const handleEditNote = (note: Note) => { // Handle both lowercase and uppercase versions from Sequelize 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); handleSelectNote(null); }; const handleNewNote = () => { setEditingNote({ title: '', content: '', tags: [], }); setIsEditing(true); handleSelectNote(null); }; const handleSaveInlineNote = async () => { if (!editingNote || !editingNote.title) return; try { // Add new tags to store if they don't exist 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); handleSelectNote(savedNote); } else { const newNote = await createNote(editingNote); setNotes([newNote, ...notes]); handleSelectNote(newNote); } setIsEditing(false); setEditingNote(null); setShowProjectDropdown(false); setShowTagsInput(false); } catch (err) { console.error('Error saving note:', err); } }; const handleCancelEdit = () => { setIsEditing(false); setEditingNote(null); setShowProjectDropdown(false); setShowTagsInput(false); if (previewNote) { // If we were editing an existing note, go back to preview 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 }; // Update local state immediately for instant feedback if (previewNote?.uid === note.uid || !note.uid) { setPreviewNote(updatedNote); } if (editingNote?.uid === note.uid || (isEditing && !note.uid)) { setEditingNote(updatedNote); } // If note is saved, persist to backend if (note.uid) { const savedNote = await updateNote(note.uid, updatedNote); const updatedNotes = notes.map((n) => n.uid === note.uid ? savedNote : n ); setNotes(updatedNotes); // Update states with backend response 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]); // Sort the filtered notes 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]); // Load note from URL parameter 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) { // If note not found and no note selected, select first note on desktop const isDesktop = window.innerWidth >= 768; if (isDesktop) { handleSelectNote(sortedNotes[0]); hasAutoSelected.current = true; } } } }, [uid, sortedNotes]); // Auto-select first note when notes are loaded (only on desktop) useEffect(() => { if ( !uid && sortedNotes.length > 0 && !previewNote && !hasAutoSelected.current ) { // Check if we're on desktop (md breakpoint is 768px) const isDesktop = window.innerWidth >= 768; if (isDesktop) { handleSelectNote(sortedNotes[0]); hasAutoSelected.current = true; } } }, [sortedNotes, previewNote, uid]); // Handle clicking outside sort dropdown to close it useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( sortDropdownRef.current && !sortDropdownRef.current.contains(event.target as Node) ) { setShowSortDropdown(false); } if ( noteOptionsDropdownRef.current && !noteOptionsDropdownRef.current.contains(event.target as Node) ) { setShowNoteOptionsDropdown(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); // Handle Escape key to save and close editor 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 (
{/* Two-Column Layout */}
{/* Left Column - Notes List */}
{/* Notes Column Header */}

{t('notes.title')}

{/* Sort Filter Dropdown */}
{showSortDropdown && (
Sort by
{sortOptions.map((option) => ( ))}
)}
{/* Search Bar inside notes list */}
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={`p-5 cursor-pointer ${ previewNote?.uid === note.uid ? 'bg-white dark:bg-gray-900 border-b border-transparent' : index !== sortedNotes.length - 1 ? 'border-b border-gray-200/50 dark:border-gray-700/50 hover:bg-gray-50 dark:hover:bg-gray-800' : 'border-b border-transparent hover:bg-gray-50 dark:hover:bg-gray-800' }`} >

{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()}
)) )}
{/* Right Column - Preview or Editor */}
{isEditing && editingNote ? (
{/* Editor Header - matches preview structure */}
{/* Back button for mobile */} setEditingNote({ ...editingNote, title: e.target.value, }) } onClick={(e) => e.stopPropagation()} placeholder="Note title..." className="w-full text-xl md:text-2xl font-bold bg-transparent text-gray-900 dark:text-gray-100 border-none focus:outline-none focus:ring-0 p-0 mb-1" style={{ color: editingNoteColor ? shouldUseLightText( editingNoteColor ) ? '#ffffff' : '#333333' : undefined, }} autoFocus />
{editingNote.updated_at ? new Date( editingNote.updated_at ).toLocaleDateString() : 'New'}
{showNoteOptionsDropdown && (
{ENABLE_NOTE_COLOR && (
Background Color
{NOTE_COLORS.map( ( colorOption ) => ( ) )}
)}
{editingNote.uid && ( )}
)}
{/* Project Dropdown */} {showProjectDropdown && (
)} {/* Tags Input */} {showTagsInput && (
t.name)} onTagsChange={( tagNames: string[] ) => { setEditingNote({ ...editingNote, tags: tagNames.map( (name: string) => ({ name, }) ), }); }} availableTags={useStore .getState() .tagsStore.getTags()} />
)} {/* Content Editor */}