695 lines
19 KiB
JavaScript
695 lines
19 KiB
JavaScript
import React, { useCallback, useContext, useEffect, useState } from 'react'
|
|
import PropTypes from 'prop-types'
|
|
import {
|
|
Card,
|
|
Button,
|
|
Space,
|
|
Typography,
|
|
Flex,
|
|
Modal,
|
|
Form,
|
|
Input,
|
|
Switch,
|
|
Spin,
|
|
Alert,
|
|
message,
|
|
Divider,
|
|
Tag,
|
|
Dropdown
|
|
} from 'antd'
|
|
import { CaretLeftFilled, LoadingOutlined } from '@ant-design/icons'
|
|
import PlusIcon from '../../Icons/PlusIcon'
|
|
import BinIcon from '../../Icons/BinIcon'
|
|
import PersonIcon from '../../Icons/PersonIcon'
|
|
import TimeDisplay from './TimeDisplay'
|
|
import MarkdownDisplay from './MarkdownDisplay'
|
|
import axios from 'axios'
|
|
import config from '../../../config'
|
|
import { AuthContext } from '../context/AuthContext'
|
|
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
|
|
import NoteTypeSelect from './NoteTypeSelect'
|
|
import IdDisplay from './IdDisplay'
|
|
import ReloadIcon from '../../Icons/ReloadIcon'
|
|
import ExclamationOctagonIcon from '../../Icons/ExclamationOctagonIcon'
|
|
|
|
const { Text, Title } = Typography
|
|
const { TextArea } = Input
|
|
|
|
const NoteItem = ({
|
|
note,
|
|
expandedNotes,
|
|
setExpandedNotes,
|
|
fetchData,
|
|
onNewNote,
|
|
onDeleteNote,
|
|
userProfile,
|
|
onChildNoteAdded
|
|
}) => {
|
|
const [childNotes, setChildNotes] = useState({})
|
|
const [loadingChildNotes, setLoadingChildNotes] = useState(null)
|
|
|
|
const isExpanded = expandedNotes[note._id]
|
|
const hasChildNotes = childNotes[note._id] && childNotes[note._id].length > 0
|
|
const isThisNoteLoading = loadingChildNotes === note._id
|
|
|
|
let transformValue = 'rotate(0deg)'
|
|
if (isExpanded) {
|
|
transformValue = 'rotate(-90deg)'
|
|
}
|
|
|
|
const handleNoteExpand = async (noteId) => {
|
|
const newExpandedState = !expandedNotes[noteId]
|
|
|
|
setExpandedNotes((prev) => ({
|
|
...prev,
|
|
[noteId]: newExpandedState
|
|
}))
|
|
|
|
if (newExpandedState && !childNotes[noteId]) {
|
|
setLoadingChildNotes(noteId)
|
|
try {
|
|
const childNotesData = await fetchData(noteId)
|
|
setChildNotes((prev) => ({
|
|
...prev,
|
|
[noteId]: childNotesData
|
|
}))
|
|
} catch (error) {
|
|
console.error('Error fetching child notes:', error)
|
|
} finally {
|
|
setLoadingChildNotes(null)
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleNewChildNote = () => {
|
|
if (onNewNote) {
|
|
onNewNote(note._id)
|
|
}
|
|
}
|
|
|
|
const handleDeleteNote = () => {
|
|
if (onDeleteNote) {
|
|
onDeleteNote(note._id)
|
|
}
|
|
}
|
|
|
|
// Reload child notes when a new child note is added
|
|
const reloadChildNotes = async () => {
|
|
// Always fetch child notes when this function is called
|
|
// This ensures child notes are loaded even if the parent wasn't expanded before
|
|
setLoadingChildNotes(note._id)
|
|
try {
|
|
const childNotesData = await fetchData(note._id)
|
|
setChildNotes((prev) => ({
|
|
...prev,
|
|
[note._id]: childNotesData
|
|
}))
|
|
} catch (error) {
|
|
console.error('Error fetching child notes:', error)
|
|
} finally {
|
|
setLoadingChildNotes(null)
|
|
}
|
|
}
|
|
|
|
// Listen for child note additions
|
|
useEffect(() => {
|
|
if (onChildNoteAdded) {
|
|
onChildNoteAdded(note._id, reloadChildNotes)
|
|
}
|
|
}, [note._id, onChildNoteAdded])
|
|
|
|
// Check if the current user can delete this note
|
|
const canDeleteNote = userProfile && userProfile._id === note.user._id
|
|
|
|
const dropdownItems = [
|
|
{
|
|
key: 'new',
|
|
icon: <PlusIcon />,
|
|
label: 'New Note',
|
|
onClick: handleNewChildNote
|
|
}
|
|
]
|
|
|
|
// Only add delete option if user owns the note
|
|
if (canDeleteNote) {
|
|
dropdownItems.push({
|
|
key: 'delete',
|
|
label: 'Delete Note',
|
|
icon: <BinIcon />,
|
|
onClick: handleDeleteNote,
|
|
danger: true
|
|
})
|
|
}
|
|
|
|
return (
|
|
<Card
|
|
key={note._id}
|
|
size='small'
|
|
style={{
|
|
backgroundColor: note.noteType.color + '26',
|
|
textAlign: 'left'
|
|
}}
|
|
>
|
|
<Flex vertical gap={'small'}>
|
|
<Flex gap={'middle'} align='start'>
|
|
<Space>
|
|
<PersonIcon />
|
|
<Text style={{ whiteSpace: 'nowrap' }}>{note.user.name}:</Text>
|
|
</Space>
|
|
<div style={{ marginBottom: '4px' }}>
|
|
<MarkdownDisplay content={note.content} />
|
|
</div>
|
|
</Flex>
|
|
<Divider style={{ margin: 0 }} />
|
|
<Flex wrap gap={'small'}>
|
|
<Dropdown
|
|
menu={{ items: dropdownItems }}
|
|
trigger={['hover']}
|
|
placement='bottomLeft'
|
|
>
|
|
<Button size='small'>Actions</Button>
|
|
</Dropdown>
|
|
<Space size={'small'} style={{ marginRight: 8 }}>
|
|
<Text type='secondary'>Type:</Text>
|
|
<Tag color={note.noteType.color} style={{ margin: 0 }}>
|
|
{note.noteType.name}
|
|
</Tag>
|
|
</Space>
|
|
<Space size={'small'} style={{ marginRight: 8 }}>
|
|
<Text type='secondary'>User ID:</Text>
|
|
<IdDisplay
|
|
longId={false}
|
|
id={note.user._id}
|
|
type={'user'}
|
|
showHyperlink={true}
|
|
/>
|
|
</Space>
|
|
<Space size={'small'} style={{ marginRight: 8 }}>
|
|
<Text type='secondary'>Created At:</Text>
|
|
<TimeDisplay dateTime={note.createdAt} showSince={true} />
|
|
</Space>
|
|
<Flex style={{ flexGrow: 1 }} justify='end'>
|
|
<Space size={'small'}>
|
|
<Button
|
|
icon={
|
|
isThisNoteLoading ? <LoadingOutlined /> : <CaretLeftFilled />
|
|
}
|
|
size='small'
|
|
type='text'
|
|
loading={isThisNoteLoading}
|
|
disabled={isThisNoteLoading}
|
|
style={{
|
|
transform: transformValue,
|
|
transition: 'transform 0.2s ease'
|
|
}}
|
|
onClick={() => handleNoteExpand(note._id)}
|
|
/>
|
|
</Space>
|
|
</Flex>
|
|
</Flex>
|
|
{isExpanded && (
|
|
<>
|
|
<Flex vertical gap={'small'} style={{ flexGrow: 1 }}>
|
|
{hasChildNotes ? (
|
|
childNotes[note._id].map((childNote) => (
|
|
<NoteItem
|
|
key={childNote._id}
|
|
note={childNote}
|
|
expandedNotes={expandedNotes}
|
|
setExpandedNotes={setExpandedNotes}
|
|
fetchData={fetchData}
|
|
onNewNote={onNewNote}
|
|
onDeleteNote={onDeleteNote}
|
|
userProfile={userProfile}
|
|
onChildNoteAdded={onChildNoteAdded}
|
|
/>
|
|
))
|
|
) : !isThisNoteLoading ? (
|
|
<Card size='small'>
|
|
<Flex
|
|
justify='center'
|
|
gap={'small'}
|
|
style={{ height: '100%' }}
|
|
align='center'
|
|
>
|
|
<Text type='secondary'>
|
|
<InfoCircleIcon />
|
|
</Text>
|
|
<Text type='secondary'>No child notes.</Text>
|
|
</Flex>
|
|
</Card>
|
|
) : null}
|
|
</Flex>
|
|
</>
|
|
)}
|
|
<Flex vertical gap={'middle'}></Flex>
|
|
</Flex>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
NoteItem.propTypes = {
|
|
note: PropTypes.object.isRequired,
|
|
expandedNotes: PropTypes.object.isRequired,
|
|
setExpandedNotes: PropTypes.func.isRequired,
|
|
fetchData: PropTypes.func.isRequired,
|
|
onNewNote: PropTypes.func,
|
|
onDeleteNote: PropTypes.func,
|
|
userProfile: PropTypes.object,
|
|
onChildNoteAdded: PropTypes.func
|
|
}
|
|
|
|
const NotesPanel = ({ _id, onNewNote }) => {
|
|
const [newNoteOpen, setNewNoteOpen] = useState(false)
|
|
const [showMarkdown, setShowMarkdown] = useState(false)
|
|
const [loading, setLoading] = useState(true)
|
|
const [messageApi, contextHolder] = message.useMessage()
|
|
const [newNoteFormLoading, setNewNoteFormLoading] = useState(false)
|
|
const [newNoteFormValues, setNewNoteFormValues] = useState({})
|
|
const [deleteNoteLoading, setDeleteNoteLoading] = useState(false)
|
|
const [doneEnabled, setDoneEnabled] = useState(false)
|
|
const [error, setError] = useState(null)
|
|
const [notes, setNotes] = useState(null)
|
|
const [expandedNotes, setExpandedNotes] = useState({})
|
|
const [newNoteForm] = Form.useForm()
|
|
const [selectedParentId, setSelectedParentId] = useState(null)
|
|
const [childNoteCallbacks, setChildNoteCallbacks] = useState({})
|
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
|
|
const [noteToDelete, setNoteToDelete] = useState(null)
|
|
|
|
const newNoteFormUpdateValues = Form.useWatch([], newNoteForm)
|
|
|
|
React.useEffect(() => {
|
|
newNoteForm
|
|
.validateFields({
|
|
validateOnly: true
|
|
})
|
|
.then(() => setDoneEnabled(true))
|
|
.catch(() => setDoneEnabled(false))
|
|
}, [newNoteForm, newNoteFormUpdateValues])
|
|
|
|
const { authenticated, userProfile } = useContext(AuthContext)
|
|
|
|
const fetchData = useCallback(async (id) => {
|
|
try {
|
|
const response = await axios.get(`${config.backendUrl}/notes`, {
|
|
params: {
|
|
parent: id,
|
|
sort: 'createdAt',
|
|
order: 'ascend'
|
|
},
|
|
headers: {
|
|
Accept: 'application/json'
|
|
},
|
|
withCredentials: true
|
|
})
|
|
|
|
const newData = response.data
|
|
setLoading(false)
|
|
return newData
|
|
} catch (error) {
|
|
setNotes([])
|
|
setError(error)
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
const generateNotes = useCallback(
|
|
async (id) => {
|
|
const notesData = await fetchData(id)
|
|
|
|
if (notesData.length <= 0) {
|
|
return (
|
|
<Spin indicator={<LoadingOutlined />} spinning={loading}>
|
|
<Card>
|
|
<Flex
|
|
justify='center'
|
|
gap={'small'}
|
|
style={{ height: '100%' }}
|
|
align='center'
|
|
>
|
|
<Text type='secondary'>
|
|
<InfoCircleIcon />
|
|
</Text>
|
|
<Text type='secondary'>No notes added.</Text>
|
|
</Flex>
|
|
</Card>
|
|
</Spin>
|
|
)
|
|
}
|
|
|
|
return notesData.map((note) => (
|
|
<NoteItem
|
|
key={note._id}
|
|
note={note}
|
|
expandedNotes={expandedNotes}
|
|
setExpandedNotes={setExpandedNotes}
|
|
fetchData={fetchData}
|
|
onNewNote={handleNewNoteFromDropdown}
|
|
onDeleteNote={handleDeleteNote}
|
|
userProfile={userProfile}
|
|
onChildNoteAdded={(noteId, callback) => {
|
|
setChildNoteCallbacks((prev) => ({
|
|
...prev,
|
|
[noteId]: callback
|
|
}))
|
|
}}
|
|
/>
|
|
))
|
|
},
|
|
[loading, fetchData, expandedNotes, userProfile]
|
|
)
|
|
|
|
const handleNewNote = async () => {
|
|
setNewNoteFormLoading(true)
|
|
try {
|
|
await axios.post(
|
|
`${config.backendUrl}/notes`,
|
|
{ ...newNoteFormValues, parent: selectedParentId || _id },
|
|
{
|
|
withCredentials: true
|
|
}
|
|
)
|
|
setNewNoteOpen(false)
|
|
messageApi.success('Added a new note.')
|
|
|
|
// If this is a child note, expand the parent and reload child notes
|
|
if (selectedParentId) {
|
|
// Ensure parent is expanded
|
|
setExpandedNotes((prev) => ({
|
|
...prev,
|
|
[selectedParentId]: true
|
|
}))
|
|
|
|
// Add a small delay to ensure state update has taken effect
|
|
setTimeout(() => {
|
|
// Reload child notes for the parent
|
|
if (childNoteCallbacks[selectedParentId]) {
|
|
childNoteCallbacks[selectedParentId]()
|
|
}
|
|
}, 100)
|
|
} else {
|
|
// If it's a top-level note, reload all notes
|
|
setLoading(true)
|
|
handleReloadData()
|
|
}
|
|
|
|
setSelectedParentId(null)
|
|
} catch (error) {
|
|
messageApi.error('Error creating new note: ' + error.message)
|
|
} finally {
|
|
setNewNoteFormLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleNewNoteFromDropdown = (parentId) => {
|
|
setSelectedParentId(parentId)
|
|
setNewNoteOpen(true)
|
|
newNoteForm.resetFields()
|
|
setNewNoteFormValues({})
|
|
}
|
|
|
|
const handleDeleteNote = async (noteId) => {
|
|
setNoteToDelete(noteId)
|
|
setDeleteConfirmOpen(true)
|
|
}
|
|
|
|
const confirmDeleteNote = async () => {
|
|
if (!noteToDelete) return
|
|
setDeleteNoteLoading(true)
|
|
|
|
try {
|
|
await axios.delete(`${config.backendUrl}/notes/${noteToDelete}`, {
|
|
withCredentials: true
|
|
})
|
|
messageApi.success('Note deleted successfully.')
|
|
|
|
// Reload all top-level notes
|
|
setLoading(true)
|
|
handleReloadData()
|
|
|
|
// Reload child notes for all expanded parents to ensure UI stays in sync
|
|
const expandedNoteIds = Object.keys(expandedNotes).filter(
|
|
(id) => expandedNotes[id]
|
|
)
|
|
for (const parentId of expandedNoteIds) {
|
|
if (childNoteCallbacks[parentId]) {
|
|
childNoteCallbacks[parentId]()
|
|
}
|
|
}
|
|
} catch (error) {
|
|
messageApi.error('Error deleting note: ' + error.message)
|
|
} finally {
|
|
setDeleteNoteLoading(false)
|
|
setDeleteConfirmOpen(false)
|
|
setNoteToDelete(null)
|
|
}
|
|
}
|
|
|
|
const cancelDeleteNote = () => {
|
|
setDeleteConfirmOpen(false)
|
|
setNoteToDelete(null)
|
|
}
|
|
|
|
const handleReloadData = useCallback(async () => {
|
|
setNotes(await generateNotes(_id))
|
|
}, [_id, generateNotes])
|
|
|
|
useEffect(() => {
|
|
if (authenticated) {
|
|
handleReloadData()
|
|
}
|
|
}, [authenticated, handleReloadData])
|
|
|
|
const handleModalOk = async () => {
|
|
try {
|
|
const values = await newNoteForm.validateFields()
|
|
onNewNote(values)
|
|
newNoteForm.resetFields()
|
|
setNewNoteOpen(false)
|
|
setShowMarkdown(false)
|
|
} catch (error) {
|
|
console.error('Validation failed:', error)
|
|
}
|
|
}
|
|
|
|
const handleModalCancel = () => {
|
|
newNoteForm.resetFields()
|
|
setNewNoteOpen(false)
|
|
setShowMarkdown(false)
|
|
setSelectedParentId(null)
|
|
}
|
|
|
|
const actionItems = {
|
|
items: [
|
|
{
|
|
label: 'New Note',
|
|
key: 'newNote',
|
|
icon: <PlusIcon />
|
|
},
|
|
{ type: 'divider' },
|
|
{
|
|
label: 'Reload Notes',
|
|
key: 'reloadNotes',
|
|
icon: <ReloadIcon />
|
|
}
|
|
],
|
|
onClick: ({ key }) => {
|
|
if (key === 'reloadNotes') {
|
|
setLoading(true)
|
|
handleReloadData()
|
|
} else if (key === 'newNote') {
|
|
setSelectedParentId(null)
|
|
setNewNoteOpen(true)
|
|
newNoteForm.resetFields()
|
|
setNewNoteFormValues({})
|
|
}
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Flex vertical gap='large' style={{ width: '100%' }}>
|
|
{contextHolder}
|
|
<Flex justify='space-between'>
|
|
<Space size={'small'}>
|
|
<Dropdown menu={actionItems} disabled={loading}>
|
|
<Button disabled={loading}>Actions</Button>
|
|
</Dropdown>
|
|
</Space>
|
|
<Space size={'small'}>
|
|
<Button
|
|
type='primary'
|
|
icon={<PlusIcon />}
|
|
disabled={loading}
|
|
onClick={() => {
|
|
setSelectedParentId(null)
|
|
setNewNoteOpen(true)
|
|
newNoteForm.resetFields()
|
|
setNewNoteFormValues({})
|
|
}}
|
|
/>
|
|
</Space>
|
|
</Flex>
|
|
|
|
<Space direction='vertical' size='middle' style={{ width: '100%' }}>
|
|
<Spin indicator={<LoadingOutlined />} spinning={loading}>
|
|
{error ? (
|
|
<Alert message={error?.message} type='error' showIcon={true} />
|
|
) : (
|
|
<Flex vertical gap={'middle'}>
|
|
{notes}
|
|
</Flex>
|
|
)}
|
|
</Spin>
|
|
</Space>
|
|
|
|
<Modal
|
|
open={newNoteOpen}
|
|
onOk={handleModalOk}
|
|
onCancel={handleModalCancel}
|
|
width={800}
|
|
closeIcon={false}
|
|
destroyOnHidden={true}
|
|
footer={false}
|
|
>
|
|
<Flex vertical gap='large'>
|
|
<Flex vertical gap='middle'>
|
|
<Flex align='center' justify='space-between'>
|
|
<Title level={2} style={{ marginTop: 0, marginBottom: 4 }}>
|
|
New Note
|
|
</Title>
|
|
<Space gap={'small'}>
|
|
<Text type='secondary'>Markdown:</Text>
|
|
<Switch onChange={setShowMarkdown} size='small' />
|
|
</Space>
|
|
</Flex>
|
|
<Form
|
|
form={newNoteForm}
|
|
layout='vertical'
|
|
onFinish={handleNewNote}
|
|
initialValues={{ content: '' }}
|
|
onValuesChange={(changedValues) =>
|
|
setNewNoteFormValues((prevValues) => ({
|
|
...prevValues,
|
|
...changedValues
|
|
}))
|
|
}
|
|
>
|
|
<Flex vertical gap={'large'}>
|
|
<Flex gap='middle' wrap>
|
|
<Form.Item
|
|
name='content'
|
|
rules={[{ required: true, message: '' }]}
|
|
style={{ margin: 0, flexGrow: 1, minWidth: '300px' }}
|
|
>
|
|
<TextArea
|
|
rows={6}
|
|
placeholder='Enter note content'
|
|
style={{ resize: 'none' }}
|
|
/>
|
|
</Form.Item>
|
|
|
|
{showMarkdown && (
|
|
<Card
|
|
style={{
|
|
flexGrow: 1,
|
|
minWidth: '300px',
|
|
backgroundColor: () => {
|
|
if (newNoteFormValues?.noteType?.color) {
|
|
return newNoteFormValues.noteType.color + '26'
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
<MarkdownDisplay
|
|
content={newNoteForm.getFieldValue('content') || ''}
|
|
/>
|
|
</Card>
|
|
)}
|
|
</Flex>
|
|
<Form.Item
|
|
name='noteType'
|
|
style={{ margin: 0 }}
|
|
rules={[
|
|
{ required: true, message: 'Please select a note type' }
|
|
]}
|
|
>
|
|
<NoteTypeSelect />
|
|
</Form.Item>
|
|
</Flex>
|
|
</Form>
|
|
</Flex>
|
|
<Flex justify='end'>
|
|
<Button
|
|
style={{ margin: '0 8px' }}
|
|
disabled={newNoteFormLoading}
|
|
onClick={() => {
|
|
setNewNoteOpen(false)
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
|
|
<Button
|
|
type='primary'
|
|
loading={newNoteFormLoading}
|
|
onClick={() => {
|
|
newNoteForm.submit()
|
|
}}
|
|
disabled={newNoteFormLoading || !doneEnabled}
|
|
>
|
|
Done
|
|
</Button>
|
|
</Flex>
|
|
</Flex>
|
|
</Modal>
|
|
|
|
<Modal
|
|
open={deleteConfirmOpen}
|
|
title={
|
|
<Space size={'middle'}>
|
|
<ExclamationOctagonIcon />
|
|
Confirm Delete
|
|
</Space>
|
|
}
|
|
onOk={confirmDeleteNote}
|
|
onCancel={cancelDeleteNote}
|
|
okText='Delete'
|
|
cancelText='Cancel'
|
|
okType='danger'
|
|
closable={false}
|
|
centered
|
|
maskClosable={false}
|
|
footer={[
|
|
<Button
|
|
key='cancel'
|
|
onClick={cancelDeleteNote}
|
|
disabled={deleteNoteLoading}
|
|
>
|
|
Cancel
|
|
</Button>,
|
|
<Button
|
|
key='delete'
|
|
type='primary'
|
|
danger
|
|
onClick={confirmDeleteNote}
|
|
loading={deleteNoteLoading}
|
|
disabled={deleteNoteLoading}
|
|
>
|
|
Delete
|
|
</Button>
|
|
]}
|
|
>
|
|
<Text>Are you sure you want to delete this note?</Text>
|
|
</Modal>
|
|
</Flex>
|
|
)
|
|
}
|
|
|
|
NotesPanel.propTypes = {
|
|
_id: PropTypes.string.isRequired,
|
|
onNewNote: PropTypes.func
|
|
}
|
|
|
|
export default NotesPanel
|