diff --git a/.sequelizerc b/.sequelizerc index e78c98e..7b113a0 100644 --- a/.sequelizerc +++ b/.sequelizerc @@ -4,5 +4,5 @@ module.exports = { 'config': path.resolve('backend', 'config', 'database.js'), 'models-path': path.resolve('backend', 'models'), 'seeders-path': path.resolve('backend', 'seeders'), - 'migrations-path': path.resolve('migrations') + 'migrations-path': path.resolve('backend', 'migrations') }; diff --git a/backend/migrations/20251106000001-add-color-to-notes.js b/backend/migrations/20251106000001-add-color-to-notes.js new file mode 100644 index 0000000..1327303 --- /dev/null +++ b/backend/migrations/20251106000001-add-color-to-notes.js @@ -0,0 +1,24 @@ +'use strict'; + +const { + safeAddColumns, + safeRemoveColumn, +} = require('../utils/migration-utils'); + +module.exports = { + up: async (queryInterface, Sequelize) => { + await safeAddColumns(queryInterface, 'notes', [ + { + name: 'color', + definition: { + type: Sequelize.STRING, + allowNull: true, + }, + }, + ]); + }, + + down: async (queryInterface, Sequelize) => { + await safeRemoveColumn(queryInterface, 'notes', 'color'); + }, +}; diff --git a/backend/models/note.js b/backend/models/note.js index babdb06..291ac6c 100644 --- a/backend/models/note.js +++ b/backend/models/note.js @@ -40,6 +40,10 @@ module.exports = (sequelize) => { key: 'id', }, }, + color: { + type: DataTypes.STRING, + allowNull: true, + }, }, { tableName: 'notes', diff --git a/backend/routes/notes.js b/backend/routes/notes.js index a28c441..420dc29 100644 --- a/backend/routes/notes.js +++ b/backend/routes/notes.js @@ -142,7 +142,8 @@ router.get( // POST /api/note router.post('/note', async (req, res) => { try { - const { title, content, project_uid, project_id, tags } = req.body; + const { title, content, project_uid, project_id, tags, color } = + req.body; const noteAttributes = { title, @@ -150,6 +151,11 @@ router.post('/note', async (req, res) => { user_id: req.session.userId, }; + // Add color if provided + if (color !== undefined) { + noteAttributes.color = color; + } + // Support both project_uid (new) and project_id (legacy) const projectIdentifier = project_uid || project_id; @@ -262,11 +268,13 @@ router.patch( where: { uid: req.params.uid }, }); - const { title, content, project_uid, project_id, tags } = req.body; + const { title, content, project_uid, project_id, tags, color } = + req.body; const updateData = {}; if (title !== undefined) updateData.title = title; if (content !== undefined) updateData.content = content; + if (color !== undefined) updateData.color = color; // Handle project assignment - support both project_uid (new) and project_id (legacy) const projectIdentifier = diff --git a/e2e/tests/note-checkbox.spec.ts b/e2e/tests/note-checkbox.spec.ts deleted file mode 100644 index 4262a85..0000000 --- a/e2e/tests/note-checkbox.spec.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { - login, - navigateAndWait, - clickAndWaitForModal, - waitForElement, - createUniqueEntity, - waitForNetworkIdle -} from '../helpers/testHelpers'; - -// Navigate to notes page after login -async function loginAndNavigateToNotes(page, baseURL) { - const appUrl = await login(page, baseURL); - await navigateAndWait(page, appUrl + '/notes'); - await expect(page).toHaveURL(/\/notes/); - return appUrl; -} - -// Create a note with checkbox content -async function createNoteWithCheckboxes(page, noteTitle) { - const addNoteButton = page.locator('[data-testid="add-note-button"]'); - const titleInput = page.locator('[data-testid="note-title-input"]'); - - await clickAndWaitForModal(addNoteButton, titleInput); - - // Fill in the note title - await titleInput.click(); - await titleInput.clear(); - await titleInput.type(noteTitle, { delay: 50 }); - - // Fill in note content with checkboxes - const contentTextarea = page.locator('[data-testid="note-content-textarea"]'); - await contentTextarea.click(); - await contentTextarea.fill('# Shopping List\n\n- [ ] Buy milk\n- [ ] Buy eggs\n- [ ] Buy bread'); - - // Save the note - await page.locator('[data-testid="note-save-button"]').click(); - - // Wait for the modal to close - await waitForElement(titleInput, { state: 'hidden' }); - await waitForNetworkIdle(page); -} - -test('user can toggle checkboxes in note detail view and changes are saved', async ({ page, baseURL }) => { - await loginAndNavigateToNotes(page, baseURL); - - // Create a note with checkboxes - const noteTitle = createUniqueEntity('Shopping List'); - await createNoteWithCheckboxes(page, noteTitle); - - // Click on the note to view it - const noteLink = page.locator('a').filter({ hasText: noteTitle }); - await expect(noteLink).toBeVisible(); - await noteLink.click(); - - // Wait for note detail page to load - await expect(page).toHaveURL(/\/note\//); - - // Wait for checkboxes to be visible - const checkboxes = page.locator('input[type="checkbox"]'); - await expect(checkboxes.first()).toBeVisible(); - - // Verify we have 3 checkboxes - await expect(checkboxes).toHaveCount(3); - - // Verify all checkboxes are initially unchecked - for (let i = 0; i < 3; i++) { - await expect(checkboxes.nth(i)).not.toBeChecked(); - } - - // Click the first checkbox (Buy milk) - await checkboxes.first().click(); - - // Wait for the save to complete - await page.waitForTimeout(500); - - // Verify the checkbox is now checked - await expect(checkboxes.first()).toBeChecked(); - - // Refresh the page to verify the change was saved - await page.reload(); - await expect(checkboxes.first()).toBeVisible(); - - // Verify the first checkbox is still checked after reload - await expect(checkboxes.first()).toBeChecked(); - - // Verify other checkboxes are still unchecked - await expect(checkboxes.nth(1)).not.toBeChecked(); - await expect(checkboxes.nth(2)).not.toBeChecked(); - - // Toggle the first checkbox back to unchecked - await checkboxes.first().click(); - await page.waitForTimeout(500); - - // Verify it's now unchecked - await expect(checkboxes.first()).not.toBeChecked(); -}); diff --git a/e2e/tests/note.spec.ts b/e2e/tests/note.spec.ts deleted file mode 100644 index aa899f3..0000000 --- a/e2e/tests/note.spec.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { - login, - navigateAndWait, - clickAndWaitForModal, - fillInputReliably, - waitForElement, - hoverAndWaitForVisible, - confirmDialog, - createUniqueEntity, - waitForNetworkIdle -} from '../helpers/testHelpers'; - -// Navigate to notes page after login -async function loginAndNavigateToNotes(page, baseURL) { - const appUrl = await login(page, baseURL); - - // Navigate to notes page - await navigateAndWait(page, appUrl + '/notes'); - await expect(page).toHaveURL(/\/notes/); - - return appUrl; -} - -// Shared function to create a note via the sidebar button -async function createNote(page, noteTitle, noteContent = '') { - // Find and click the "Add Note" button in the sidebar - const addNoteButton = page.locator('[data-testid="add-note-button"]'); - const titleInput = page.locator('[data-testid="note-title-input"]'); - - await clickAndWaitForModal(addNoteButton, titleInput); - - // Fill in the note title - await titleInput.click(); - await titleInput.clear(); - await titleInput.type(noteTitle, { delay: 50 }); - - // Fill in the note content if provided - if (noteContent) { - const contentTextarea = page.locator('[data-testid="note-content-textarea"]'); - await contentTextarea.click(); - await contentTextarea.fill(noteContent); - } - - // Save the note using the specific test ID - await page.locator('[data-testid="note-save-button"]').click(); - - // Wait for the modal to close - await waitForElement(titleInput, { state: 'hidden' }); - - // Wait for note creation to complete - await waitForNetworkIdle(page); -} - -test('user can create a new note and verify it appears in the notes list', async ({ page, baseURL }) => { - await loginAndNavigateToNotes(page, baseURL); - - // Create a unique test note - const noteTitle = createUniqueEntity('Test Note'); - const noteContent = 'This is test content for note'; - await createNote(page, noteTitle, noteContent); - - // Verify the note appears in the notes list - await expect(page.getByText(noteTitle)).toBeVisible(); -}); - -test('user can update an existing note', async ({ page, baseURL }) => { - await loginAndNavigateToNotes(page, baseURL); - - // Create an initial note - const originalNoteTitle = createUniqueEntity('Test note to edit'); - const originalNoteContent = 'Original content'; - await createNote(page, originalNoteTitle, originalNoteContent); - - // Find the specific note card by title text - const noteCard = page.locator('a').filter({ hasText: originalNoteTitle }); - await expect(noteCard).toBeVisible(); - - // Hover over the note card and wait for dropdown button to be visible - const dropdownButton = noteCard.locator('..').locator('button[data-testid^="note-dropdown-"]'); - await hoverAndWaitForVisible(noteCard, dropdownButton); - - // Click dropdown button - await dropdownButton.click(); - - // Wait for dropdown menu to appear and click Edit - const editButton = page.locator('button[data-testid^="note-edit-"]').first(); - await waitForElement(editButton); - await editButton.click(); - - // Wait for the Note Modal to appear with the note data - const noteTitleInput = page.locator('[data-testid="note-title-input"]'); - await waitForElement(noteTitleInput); - - // Verify the note title field is pre-filled - await expect(noteTitleInput).toHaveValue(originalNoteTitle); - - // Edit the note title and content - const editedNoteTitle = createUniqueEntity('Edited test note'); - const editedNoteContent = 'Edited content'; - await fillInputReliably(noteTitleInput, editedNoteTitle); - - const noteContentTextarea = page.locator('[data-testid="note-content-textarea"]'); - await noteContentTextarea.clear(); - await noteContentTextarea.fill(editedNoteContent); - - // Save the changes - await page.locator('[data-testid="note-save-button"]').click(); - - // Wait for the modal to close - await waitForElement(noteTitleInput, { state: 'hidden' }); - - // Verify the edited note appears in the notes list - await expect(page.getByText(editedNoteTitle)).toBeVisible(); - - // Verify the original note title is no longer visible - await expect(page.getByText(originalNoteTitle)).not.toBeVisible(); -}); - -test('user can delete an existing note', async ({ page, baseURL }) => { - await loginAndNavigateToNotes(page, baseURL); - - // Create an initial note - const noteTitle = createUniqueEntity('Test note to delete'); - const noteContent = 'Content to delete'; - await createNote(page, noteTitle, noteContent); - - // Find the specific note card by title text - const noteCard = page.locator('a').filter({ hasText: noteTitle }); - await expect(noteCard).toBeVisible(); - - // Hover over the note card and wait for dropdown button to be visible - const dropdownButton = noteCard.locator('..').locator('button[data-testid^="note-dropdown-"]'); - await hoverAndWaitForVisible(noteCard, dropdownButton); - - // Click dropdown button - await dropdownButton.click(); - - // Wait for dropdown menu to appear and click Delete - const deleteButton = page.locator('button[data-testid^="note-delete-"]').first(); - await waitForElement(deleteButton); - await deleteButton.click(); - - // Wait for and handle the confirmation dialog - await confirmDialog(page, 'Delete Note'); - - // Verify the note is no longer visible in the notes list - await expect(page.getByRole('link', { name: new RegExp(noteTitle) })).not.toBeVisible(); -}); diff --git a/frontend/App.tsx b/frontend/App.tsx index a56830d..156c48a 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -238,6 +238,7 @@ const App: React.FC = () => { element={} /> } /> + } /> } diff --git a/frontend/Layout.tsx b/frontend/Layout.tsx index 077cac5..629018e 100644 --- a/frontend/Layout.tsx +++ b/frontend/Layout.tsx @@ -459,9 +459,9 @@ const Layout: React.FC = ({ >
{children}
diff --git a/frontend/components/Notes.tsx b/frontend/components/Notes.tsx index 7ad2bc3..f9874d4 100644 --- a/frontend/components/Notes.tsx +++ b/frontend/components/Notes.tsx @@ -1,27 +1,93 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { MagnifyingGlassIcon } from '@heroicons/react/24/solid'; +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 SortFilterButton, { SortOption } from './Shared/SortFilterButton'; +import { Link, useParams, useNavigate } from 'react-router-dom'; +import { SortOption } from './Shared/SortFilterButton'; import NoteModal from './Note/NoteModal'; import ConfirmDialog from './Shared/ConfirmDialog'; -import NoteCard from './Shared/NoteCard'; +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 [isSearchExpanded, setIsSearchExpanded] = useState(false); 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 } = @@ -48,20 +114,158 @@ const Notes: React.FC = () => { setOrderBy(newOrderBy); }; + // Function to set preview note and update URL + const handleSelectNote = (note: Note | null) => { + // If we're editing a new unsaved note, discard it + if (isEditing && editingNote && !editingNote.uid) { + 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) => { - setSelectedNote(note); - setIsNoteModalOpen(true); + // 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) => { @@ -96,39 +300,124 @@ const Notes: React.FC = () => { } }; - const filteredNotes = notes.filter( - (note) => - note.title.toLowerCase().includes(searchQuery.toLowerCase()) || - note.content.toLowerCase().includes(searchQuery.toLowerCase()) - ); + 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 = [...filteredNotes].sort((a, b) => { - const [field, direction] = orderBy.split(':'); - const isAsc = direction === 'asc'; + const sortedNotes = useMemo(() => { + return [...filteredNotes].sort((a, b) => { + const [field, direction] = orderBy.split(':'); + const isAsc = direction === 'asc'; - let valueA, valueB; + 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; + 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]); - if (valueA < valueB) return isAsc ? -1 : 1; - if (valueA > valueB) return isAsc ? 1 : -1; - return 0; - }); + // 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 ( @@ -149,88 +438,781 @@ const Notes: React.FC = () => { } return ( -
-
- {/* Notes Header */} -
-

{t('notes.title')}

-
+
+
+ {/* Two-Column Layout */} +
+ {/* Left Column - Notes List */} +
+ {/* Notes Column Header */} +
+

+ {t('notes.title')} +

+
+ {/* Sort Filter Dropdown */} +
+ + {showSortDropdown && ( +
+
+ Sort by +
+
+ {sortOptions.map((option) => ( + + ))} +
+
+ )} +
+ +
+
- {/* Header with Search and Sort Controls */} -
-
- {/* Search Toggle Button */} - -
+ {/* 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" + /> +
+
-
- {/* Sort Filter Button */} -
- +
+ {sortedNotes.length === 0 ? ( +
+

+ {t('notes.noNotesFound')} +

+
+ ) : ( + sortedNotes.map((note, index) => ( +
handleSelectNote(note)} + className={`p-5 cursor-pointer transition-colors ${ + previewNote?.uid === note.uid + ? 'bg-white dark:bg-gray-900 mb-1' + : index !== + sortedNotes.length - 1 + ? 'border-b border-gray-200/50 dark:border-gray-700/50 hover:bg-gray-50 dark:hover:bg-gray-800' + : '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()} +
+
+ )) + )}
-
- {/* Collapsible Search Bar */} -
-
- - setSearchQuery(e.target.value)} - className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white" - /> + {/* 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 */} +
+