1351 lines
75 KiB
TypeScript
1351 lines
75 KiB
TypeScript
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<Note | null>(null);
|
|
const [previewNote, setPreviewNote] = useState<Note | null>(null);
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [editingNote, setEditingNote] = useState<Note | null>(null);
|
|
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
|
|
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
|
|
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [orderBy, setOrderBy] = useState<string>('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<HTMLDivElement>(null);
|
|
const noteOptionsDropdownRef = useRef<HTMLDivElement>(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<HTMLButtonElement>
|
|
) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setShowProjectDropdown((prev) => !prev);
|
|
setShowTagsInput(false);
|
|
};
|
|
|
|
const handleTagsButtonClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
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 (
|
|
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
|
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
|
|
{t('notes.loading')}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isError) {
|
|
return (
|
|
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
|
<div className="text-red-500 text-lg">{t('notes.error')}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col h-[calc(100vh-6rem)] overflow-hidden">
|
|
<div className="flex-1 flex flex-col min-h-0 overflow-hidden pt-2">
|
|
{/* Two-Column Layout */}
|
|
<div className="flex flex-col md:flex-row flex-1 min-h-0 overflow-hidden">
|
|
{/* Left Column - Notes List */}
|
|
<div
|
|
className={`${previewNote || isEditing ? 'hidden md:flex' : 'flex'} flex-col md:w-80 w-full h-full md:h-auto flex-shrink-0 min-h-0`}
|
|
>
|
|
{/* Notes Column Header */}
|
|
<div className="flex items-center justify-between mb-2 mx-3 flex-shrink-0">
|
|
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
|
{t('notes.title')}
|
|
</h3>
|
|
<div className="flex items-center gap-2">
|
|
{/* Sort Filter Dropdown */}
|
|
<div className="relative" ref={sortDropdownRef}>
|
|
<button
|
|
onClick={() =>
|
|
setShowSortDropdown(
|
|
!showSortDropdown
|
|
)
|
|
}
|
|
className="p-1 text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 focus:outline-none transition-colors"
|
|
aria-label="Sort notes"
|
|
>
|
|
<FunnelIcon className="h-5 w-5" />
|
|
</button>
|
|
{showSortDropdown && (
|
|
<div className="absolute right-0 mt-1 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-50">
|
|
<div className="px-3 py-2 text-xs font-semibold text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
|
|
Sort by
|
|
</div>
|
|
<div className="py-1">
|
|
{sortOptions.map((option) => (
|
|
<button
|
|
key={option.value}
|
|
onClick={() => {
|
|
handleSortChange(
|
|
option.value
|
|
);
|
|
setShowSortDropdown(
|
|
false
|
|
);
|
|
}}
|
|
className={`w-full text-left px-3 py-2 text-sm transition-colors ${
|
|
orderBy ===
|
|
option.value
|
|
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
|
|
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
|
}`}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={handleNewNote}
|
|
className="p-1 text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 focus:outline-none transition-colors"
|
|
aria-label={t('notes.addNote', 'Add Note')}
|
|
>
|
|
<PlusIcon className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Search Bar inside notes list */}
|
|
<div className="mb-2 mx-3 flex-shrink-0">
|
|
<div className="flex items-center bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md p-2">
|
|
<MagnifyingGlassIcon className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-2" />
|
|
<input
|
|
type="text"
|
|
placeholder={t('notes.searchPlaceholder')}
|
|
value={searchQuery}
|
|
onChange={(e) =>
|
|
setSearchQuery(e.target.value)
|
|
}
|
|
className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-0 overflow-y-auto flex-1 min-h-0">
|
|
{sortedNotes.length === 0 ? (
|
|
<div className="flex items-center justify-center h-full p-4">
|
|
<p className="text-gray-500 dark:text-gray-400 text-sm text-center">
|
|
{t('notes.noNotesFound')}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
sortedNotes.map((note, index) => (
|
|
<div
|
|
key={note.uid}
|
|
onClick={() => 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'
|
|
}`}
|
|
>
|
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
|
|
{note.title ||
|
|
t(
|
|
'notes.untitled',
|
|
'Untitled Note'
|
|
)}
|
|
</h3>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 line-clamp-2">
|
|
{note.content.substring(0, 100)}
|
|
{note.content.length > 100
|
|
? '...'
|
|
: ''}
|
|
</p>
|
|
<div className="text-xs text-gray-400 dark:text-gray-500 mt-2">
|
|
{new Date(
|
|
note.updated_at ||
|
|
note.created_at ||
|
|
''
|
|
).toLocaleDateString()}
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Column - Preview or Editor */}
|
|
<div
|
|
className={`${previewNote || isEditing ? 'flex' : 'hidden md:flex'} flex-1 flex-col overflow-hidden h-full rounded-md md:h-auto ${activeNoteColor ? '' : 'bg-white dark:bg-gray-900'}`}
|
|
style={{
|
|
backgroundColor: activeNoteColor || undefined,
|
|
}}
|
|
>
|
|
{isEditing && editingNote ? (
|
|
<div className="flex-1 flex flex-col overflow-hidden">
|
|
{/* Editor Header - matches preview structure */}
|
|
<div className="flex items-start justify-between mb-3 flex-shrink-0 px-6 md:px-8 pt-4">
|
|
<div className="flex-1">
|
|
{/* Back button for mobile */}
|
|
<button
|
|
onClick={() => {
|
|
if (editingNote.title) {
|
|
handleSaveInlineNote();
|
|
} else {
|
|
setShowDiscardDialog(true);
|
|
}
|
|
}}
|
|
className="md:hidden mb-2 text-blue-600 dark:text-blue-400 hover:underline text-sm"
|
|
>
|
|
← {t('common.back', 'Back to list')}
|
|
</button>
|
|
<input
|
|
type="text"
|
|
value={editingNote.title || ''}
|
|
onChange={(e) =>
|
|
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
|
|
/>
|
|
<div
|
|
className="flex flex-col md:flex-row md:flex-wrap md:items-center text-xs text-gray-500 dark:text-gray-400 space-y-1 md:space-y-0 md:gap-3 mb-2"
|
|
style={{
|
|
color: editingNoteColor
|
|
? shouldUseLightText(
|
|
editingNoteColor
|
|
)
|
|
? '#e0e0e0'
|
|
: '#333333'
|
|
: undefined,
|
|
}}
|
|
>
|
|
<div className="flex items-center">
|
|
<ClockIcon className="h-3 w-3 mr-1" />
|
|
<span>
|
|
{editingNote.updated_at
|
|
? new Date(
|
|
editingNote.updated_at
|
|
).toLocaleDateString()
|
|
: 'New'}
|
|
</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={
|
|
handleProjectButtonClick
|
|
}
|
|
className="flex items-center hover:underline text-left"
|
|
title={
|
|
editingNote.project
|
|
? 'Change project'
|
|
: 'Add project'
|
|
}
|
|
>
|
|
<FolderIcon className="h-3 w-3 mr-1" />
|
|
{editingNote.project
|
|
? editingNote.project.name
|
|
: 'Add project'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleTagsButtonClick}
|
|
className="flex items-center hover:underline text-left"
|
|
title={
|
|
editingNote.tags &&
|
|
editingNote.tags.length > 0
|
|
? 'Change tags'
|
|
: 'Add tags'
|
|
}
|
|
>
|
|
<TagIconOutline className="h-3 w-3 mr-1" />
|
|
<span>
|
|
{editingNote.tags &&
|
|
editingNote.tags.length > 0
|
|
? editingNote.tags.map(
|
|
(tag, idx) => (
|
|
<React.Fragment
|
|
key={idx}
|
|
>
|
|
{idx >
|
|
0 &&
|
|
', '}
|
|
{tag.name}
|
|
</React.Fragment>
|
|
)
|
|
)
|
|
: 'Add tags'}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div
|
|
className="relative"
|
|
ref={noteOptionsDropdownRef}
|
|
>
|
|
<button
|
|
onClick={() =>
|
|
setShowNoteOptionsDropdown(
|
|
!showNoteOptionsDropdown
|
|
)
|
|
}
|
|
className="p-2 text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
|
style={{
|
|
color: editingNoteColor
|
|
? shouldUseLightText(
|
|
editingNoteColor
|
|
)
|
|
? '#e0e0e0'
|
|
: '#333333'
|
|
: undefined,
|
|
}}
|
|
aria-label="Note options"
|
|
>
|
|
<EllipsisVerticalIcon className="h-5 w-5" />
|
|
</button>
|
|
{showNoteOptionsDropdown && (
|
|
<div className="absolute right-0 mt-1 w-56 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-50">
|
|
{ENABLE_NOTE_COLOR && (
|
|
<div className="px-3 py-3 border-b border-gray-200 dark:border-gray-700">
|
|
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-2">
|
|
Background Color
|
|
</div>
|
|
<div className="grid grid-cols-5 gap-2">
|
|
{NOTE_COLORS.map(
|
|
(
|
|
colorOption
|
|
) => (
|
|
<button
|
|
key={
|
|
colorOption.value
|
|
}
|
|
onClick={() =>
|
|
handleColorChange(
|
|
colorOption.value,
|
|
editingNote
|
|
)
|
|
}
|
|
className={`w-8 h-8 rounded-md border-2 transition-all hover:scale-110 flex items-center justify-center ${
|
|
editingNote.color ===
|
|
colorOption.value
|
|
? 'border-blue-500 dark:border-blue-400 ring-2 ring-blue-200 dark:ring-blue-800'
|
|
: 'border-gray-300 dark:border-gray-600'
|
|
}`}
|
|
style={{
|
|
backgroundColor:
|
|
colorOption.value ||
|
|
'#ffffff',
|
|
}}
|
|
title={
|
|
colorOption.name
|
|
}
|
|
aria-label={`Set background to ${colorOption.name}`}
|
|
>
|
|
{!colorOption.value && (
|
|
<XMarkIcon className="h-5 w-5 text-gray-400" />
|
|
)}
|
|
</button>
|
|
)
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="py-1">
|
|
<button
|
|
onClick={() => {
|
|
if (
|
|
editingNote.title
|
|
) {
|
|
handleSaveInlineNote();
|
|
} else {
|
|
setShowDiscardDialog(
|
|
true
|
|
);
|
|
}
|
|
setShowNoteOptionsDropdown(
|
|
false
|
|
);
|
|
}}
|
|
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
|
|
>
|
|
<PencilIcon className="h-4 w-4" />
|
|
{t(
|
|
'notes.save',
|
|
'Save'
|
|
)}
|
|
</button>
|
|
{editingNote.uid && (
|
|
<button
|
|
onClick={() => {
|
|
setNoteToDelete(
|
|
editingNote
|
|
);
|
|
setIsConfirmDialogOpen(
|
|
true
|
|
);
|
|
setShowNoteOptionsDropdown(
|
|
false
|
|
);
|
|
}}
|
|
className="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
|
|
>
|
|
<TrashIcon className="h-4 w-4" />
|
|
{t(
|
|
'notes.delete',
|
|
'Delete'
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Project Dropdown */}
|
|
{showProjectDropdown && (
|
|
<div className="mb-3 mx-4 p-3 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 flex-shrink-0">
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Select Project
|
|
</label>
|
|
<select
|
|
value={
|
|
editingNote.project_uid || ''
|
|
}
|
|
onChange={(e) => {
|
|
const projectUid =
|
|
e.target.value || null;
|
|
const selectedProject =
|
|
projectUid
|
|
? projects.find(
|
|
(p) =>
|
|
p.uid ===
|
|
projectUid
|
|
)
|
|
: undefined;
|
|
setEditingNote({
|
|
...editingNote,
|
|
project_uid: projectUid,
|
|
project:
|
|
selectedProject as any,
|
|
});
|
|
setShowProjectDropdown(false);
|
|
}}
|
|
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
|
>
|
|
<option value="">No Project</option>
|
|
{projects.map((project) => (
|
|
<option
|
|
key={project.uid}
|
|
value={project.uid}
|
|
>
|
|
{project.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tags Input */}
|
|
{showTagsInput && (
|
|
<div className="mb-3 mx-4 p-3 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 flex-shrink-0">
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Tags
|
|
</label>
|
|
<TagInput
|
|
initialTags={(
|
|
editingNote.tags ||
|
|
editingNote.Tags ||
|
|
[]
|
|
).map((t: any) => t.name)}
|
|
onTagsChange={(
|
|
tagNames: string[]
|
|
) => {
|
|
setEditingNote({
|
|
...editingNote,
|
|
tags: tagNames.map(
|
|
(name: string) => ({
|
|
name,
|
|
})
|
|
),
|
|
});
|
|
}}
|
|
availableTags={useStore
|
|
.getState()
|
|
.tagsStore.getTags()}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Content Editor */}
|
|
<div className="flex-1 overflow-y-auto px-6 md:px-8">
|
|
<textarea
|
|
value={editingNote.content || ''}
|
|
onChange={(e) =>
|
|
setEditingNote({
|
|
...editingNote,
|
|
content: e.target.value,
|
|
})
|
|
}
|
|
onClick={(e) => e.stopPropagation()}
|
|
placeholder="Write your note content here... (Markdown supported)"
|
|
className="w-full h-full min-h-[300px] bg-transparent text-gray-900 dark:text-gray-100 border-none focus:outline-none focus:ring-0 resize-none py-4"
|
|
style={{
|
|
color: editingNoteColor
|
|
? shouldUseLightText(
|
|
editingNoteColor
|
|
)
|
|
? '#ffffff'
|
|
: '#333333'
|
|
: undefined,
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : previewNote ? (
|
|
<div className="flex-1 flex flex-col overflow-hidden">
|
|
{/* Preview Header */}
|
|
<div className="flex items-start justify-between mb-3 flex-shrink-0 px-6 md:px-8 pt-6">
|
|
<div className="flex-1">
|
|
{/* Back button for mobile */}
|
|
<button
|
|
onClick={() =>
|
|
handleSelectNote(null)
|
|
}
|
|
className="md:hidden mb-2 text-blue-600 dark:text-blue-400 hover:underline text-sm"
|
|
>
|
|
← {t('common.back', 'Back to list')}
|
|
</button>
|
|
<h1
|
|
onClick={() =>
|
|
handleEditNote(previewNote)
|
|
}
|
|
className="text-xl md:text-2xl font-bold mb-1 cursor-pointer text-gray-900 dark:text-gray-100 transition-colors"
|
|
style={{
|
|
color: previewNoteColor
|
|
? shouldUseLightText(
|
|
previewNoteColor
|
|
)
|
|
? '#ffffff'
|
|
: '#333333'
|
|
: undefined,
|
|
}}
|
|
title="Click to edit"
|
|
>
|
|
{previewNote.title ||
|
|
t(
|
|
'notes.untitled',
|
|
'Untitled Note'
|
|
)}
|
|
</h1>
|
|
<div
|
|
className="flex flex-col md:flex-row md:flex-wrap md:items-center text-xs text-gray-500 dark:text-gray-400 space-y-1 md:space-y-0 md:gap-3 mb-2"
|
|
style={{
|
|
color: previewNoteColor
|
|
? shouldUseLightText(
|
|
previewNoteColor
|
|
)
|
|
? '#e0e0e0'
|
|
: '#333333'
|
|
: undefined,
|
|
}}
|
|
>
|
|
<div className="flex items-center">
|
|
<ClockIcon className="h-3 w-3 mr-1" />
|
|
<span>
|
|
{new Date(
|
|
previewNote.updated_at ||
|
|
previewNote.created_at ||
|
|
''
|
|
).toLocaleDateString()}
|
|
</span>
|
|
</div>
|
|
{(previewNote.project ||
|
|
previewNote.Project) && (
|
|
<div className="flex items-center">
|
|
<FolderIcon className="h-3 w-3 mr-1" />
|
|
<Link
|
|
to={
|
|
(
|
|
previewNote.project ||
|
|
previewNote.Project
|
|
)?.uid
|
|
? `/project/${(previewNote.project || previewNote.Project).uid}-${(
|
|
previewNote.project ||
|
|
previewNote.Project
|
|
)?.name
|
|
.toLowerCase()
|
|
.replace(
|
|
/[^a-z0-9]+/g,
|
|
'-'
|
|
)
|
|
.replace(
|
|
/^-|-$/g,
|
|
''
|
|
)}`
|
|
: `/project/${(previewNote.project || previewNote.Project)?.id}`
|
|
}
|
|
className="hover:underline"
|
|
onClick={(e) =>
|
|
e.stopPropagation()
|
|
}
|
|
>
|
|
{
|
|
(
|
|
previewNote.project ||
|
|
previewNote.Project
|
|
)?.name
|
|
}
|
|
</Link>
|
|
</div>
|
|
)}
|
|
{((previewNote.tags &&
|
|
previewNote.tags.length > 0) ||
|
|
(previewNote.Tags &&
|
|
previewNote.Tags.length >
|
|
0)) && (
|
|
<div className="flex items-center">
|
|
<TagIconOutline className="h-3 w-3 mr-1" />
|
|
<span>
|
|
{(
|
|
previewNote.tags ||
|
|
previewNote.Tags ||
|
|
[]
|
|
).map((tag, idx) => (
|
|
<React.Fragment
|
|
key={tag.name}
|
|
>
|
|
{idx > 0 &&
|
|
', '}
|
|
<Link
|
|
to={
|
|
tag.uid
|
|
? `/tag/${tag.uid}-${tag.name
|
|
.toLowerCase()
|
|
.replace(
|
|
/[^a-z0-9]+/g,
|
|
'-'
|
|
)
|
|
.replace(
|
|
/^-|-$/g,
|
|
''
|
|
)}`
|
|
: `/tag/${tag.name
|
|
.toLowerCase()
|
|
.replace(
|
|
/[^a-z0-9]+/g,
|
|
'-'
|
|
)
|
|
.replace(
|
|
/^-|-$/g,
|
|
''
|
|
)}`
|
|
}
|
|
className="hover:underline"
|
|
onClick={(
|
|
e
|
|
) =>
|
|
e.stopPropagation()
|
|
}
|
|
>
|
|
{tag.name}
|
|
</Link>
|
|
</React.Fragment>
|
|
))}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div
|
|
className="relative"
|
|
ref={noteOptionsDropdownRef}
|
|
>
|
|
<button
|
|
onClick={() =>
|
|
setShowNoteOptionsDropdown(
|
|
!showNoteOptionsDropdown
|
|
)
|
|
}
|
|
className="p-2 text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
|
style={{
|
|
color: previewNoteColor
|
|
? shouldUseLightText(
|
|
previewNoteColor
|
|
)
|
|
? '#e0e0e0'
|
|
: '#333333'
|
|
: undefined,
|
|
}}
|
|
aria-label="Note options"
|
|
>
|
|
<EllipsisVerticalIcon className="h-5 w-5" />
|
|
</button>
|
|
{showNoteOptionsDropdown && (
|
|
<div className="absolute right-0 mt-1 w-56 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-50">
|
|
{ENABLE_NOTE_COLOR && (
|
|
<div className="px-3 py-3 border-b border-gray-200 dark:border-gray-700">
|
|
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-2">
|
|
Background Color
|
|
</div>
|
|
<div className="grid grid-cols-5 gap-2">
|
|
{NOTE_COLORS.map(
|
|
(
|
|
colorOption
|
|
) => (
|
|
<button
|
|
key={
|
|
colorOption.value
|
|
}
|
|
onClick={() =>
|
|
handleColorChange(
|
|
colorOption.value,
|
|
previewNote
|
|
)
|
|
}
|
|
className={`w-8 h-8 rounded-md border-2 transition-all hover:scale-110 flex items-center justify-center ${
|
|
previewNote.color ===
|
|
colorOption.value
|
|
? 'border-blue-500 dark:border-blue-400 ring-2 ring-blue-200 dark:ring-blue-800'
|
|
: 'border-gray-300 dark:border-gray-600'
|
|
}`}
|
|
style={{
|
|
backgroundColor:
|
|
colorOption.value ||
|
|
'#ffffff',
|
|
}}
|
|
title={
|
|
colorOption.name
|
|
}
|
|
aria-label={`Set background to ${colorOption.name}`}
|
|
>
|
|
{!colorOption.value && (
|
|
<XMarkIcon className="h-5 w-5 text-gray-400" />
|
|
)}
|
|
</button>
|
|
)
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="py-1">
|
|
<button
|
|
onClick={() => {
|
|
handleEditNote(
|
|
previewNote
|
|
);
|
|
setShowNoteOptionsDropdown(
|
|
false
|
|
);
|
|
}}
|
|
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
|
|
>
|
|
<PencilIcon className="h-4 w-4" />
|
|
{t(
|
|
'notes.edit',
|
|
'Edit'
|
|
)}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setNoteToDelete(
|
|
previewNote
|
|
);
|
|
setIsConfirmDialogOpen(
|
|
true
|
|
);
|
|
setShowNoteOptionsDropdown(
|
|
false
|
|
);
|
|
}}
|
|
className="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
|
|
>
|
|
<TrashIcon className="h-4 w-4" />
|
|
{t(
|
|
'notes.delete',
|
|
'Delete'
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Preview Content */}
|
|
<div
|
|
onClick={() => handleEditNote(previewNote)}
|
|
className="text-sm md:text-base flex-1 overflow-y-auto cursor-pointer px-6 md:px-8 py-4 text-gray-900 dark:text-gray-100"
|
|
style={{
|
|
color: previewNoteColor
|
|
? shouldUseLightText(
|
|
previewNoteColor
|
|
)
|
|
? '#ffffff'
|
|
: '#333333'
|
|
: undefined,
|
|
}}
|
|
title="Click to edit"
|
|
>
|
|
<MarkdownRenderer
|
|
content={previewNote.content}
|
|
noteColor={previewNoteColor}
|
|
onContentChange={async (newContent) => {
|
|
// Update local state immediately
|
|
const updatedNote = {
|
|
...previewNote,
|
|
content: newContent,
|
|
};
|
|
setPreviewNote(updatedNote);
|
|
|
|
// Save to backend
|
|
if (previewNote.uid) {
|
|
try {
|
|
const savedNote =
|
|
await updateNote(
|
|
previewNote.uid,
|
|
updatedNote
|
|
);
|
|
// Update notes list
|
|
const updatedNotes =
|
|
notes.map((n) =>
|
|
n.uid ===
|
|
previewNote.uid
|
|
? savedNote
|
|
: n
|
|
);
|
|
setNotes(updatedNotes);
|
|
setPreviewNote(savedNote);
|
|
} catch (err) {
|
|
console.error(
|
|
'Error updating note:',
|
|
err
|
|
);
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-center flex-1 text-gray-500 dark:text-gray-400">
|
|
{t(
|
|
'notes.selectNote',
|
|
'Select a note to preview'
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* NoteModal */}
|
|
{isNoteModalOpen && (
|
|
<NoteModal
|
|
isOpen={isNoteModalOpen}
|
|
onClose={() => {
|
|
setIsNoteModalOpen(false);
|
|
}}
|
|
onSave={handleSaveNote}
|
|
onDelete={async (noteUid) => {
|
|
try {
|
|
await deleteNoteWithStoreUpdate(
|
|
noteUid,
|
|
showSuccessToast,
|
|
t
|
|
);
|
|
setIsNoteModalOpen(false);
|
|
setSelectedNote(null);
|
|
} catch (err) {
|
|
console.error('Error deleting note:', err);
|
|
}
|
|
}}
|
|
note={selectedNote}
|
|
projects={
|
|
projects?.length > 0
|
|
? projects
|
|
: ([
|
|
{
|
|
id: 1,
|
|
name: 'Test Project 1',
|
|
active: true,
|
|
priority: 'low',
|
|
},
|
|
{
|
|
id: 2,
|
|
name: 'tududi',
|
|
active: true,
|
|
priority: 'high',
|
|
},
|
|
] as any)
|
|
}
|
|
onCreateProject={handleCreateProject}
|
|
/>
|
|
)}
|
|
|
|
{/* ConfirmDialog */}
|
|
{isConfirmDialogOpen && noteToDelete && (
|
|
<ConfirmDialog
|
|
title={t('modals.deleteNote.title')}
|
|
message={t('modals.deleteNote.message', {
|
|
noteTitle: noteToDelete.title,
|
|
})}
|
|
onConfirm={handleDeleteNote}
|
|
onCancel={() => setIsConfirmDialogOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* DiscardChangesDialog */}
|
|
{showDiscardDialog && (
|
|
<DiscardChangesDialog
|
|
onDiscard={() => {
|
|
setShowDiscardDialog(false);
|
|
handleCancelEdit();
|
|
}}
|
|
onCancel={() => setShowDiscardDialog(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Notes;
|