import { useState, useEffect, useCallback, useMemo } from 'react' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' import ReactDOM from 'react-dom' import { Plus, Pencil, X, StickyNote, Settings } from 'lucide-react' import { collabApi } from '../../api/client' import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' import { addListener, removeListener } from '../../api/websocket' import { useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' import ConfirmDialog from '../shared/ConfirmDialog' import type { User } from '../../types' import type { CollabNote } from './CollabNotes.types' import { FONT, NOTE_COLORS } from './CollabNotes.constants' import { NoteFormModal } from './CollabNotesFormModal' import { CategorySettingsModal } from './CollabNotesCategorySettingsModal' import { NoteCard } from './CollabNotesCard' import { FilePreviewPortal } from './CollabNotesFilePreviewPortal' import { AuthedImg } from './CollabNotesAuthedImg' // ── Main Component ────────────────────────────────────────────────────────── interface CollabNotesProps { tripId: number currentUser: User } /** * Collab notes state: load + WebSocket sync, note CRUD (with file uploads), * category colors/renames and the view/edit/settings modal toggles. The shell * below renders the header, category pills, the note grid and the modals. */ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) { const { t } = useTranslation() const toast = useToast() const can = useCanDo() const trip = useTripStore((s) => s.trip) const canEdit = can('collab_edit', trip) const [notes, setNotes] = useState([]) const [loading, setLoading] = useState(true) const [showNewModal, setShowNewModal] = useState(false) const [editingNote, setEditingNote] = useState(null) const [viewingNote, setViewingNote] = useState(null) const [previewFile, setPreviewFile] = useState(null) const [showSettings, setShowSettings] = useState(false) const [activeCategory, setActiveCategory] = useState(null) const [pendingDeleteNoteId, setPendingDeleteNoteId] = useState(null) // Empty categories (no notes yet) stored in localStorage const [emptyCategories, setEmptyCategories] = useState(() => { try { return JSON.parse(localStorage.getItem(`collab-cats-${tripId}`)) || {} } catch { return {} } }) const saveEmptyCategories = (map) => { setEmptyCategories(map) localStorage.setItem(`collab-cats-${tripId}`, JSON.stringify(map)) } // Category colors: from notes first, then from empty categories const categoryColors = useMemo(() => { const map = { ...emptyCategories } for (const n of notes) { if (n.category && n.color) map[n.category] = n.color } return map }, [notes, emptyCategories]) const getCategoryColor = (cat) => { if (!cat) return NOTE_COLORS[0].value if (categoryColors[cat]) return categoryColors[cat] return NOTE_COLORS[Object.keys(categoryColors).length % NOTE_COLORS.length].value } // ── Load notes on mount ── useEffect(() => { if (!tripId) return let cancelled = false setLoading(true) collabApi.getNotes(tripId) .then(data => { if (!cancelled) setNotes(data?.notes || data || []) }) .catch(() => { if (!cancelled) setNotes([]) }) .finally(() => { if (!cancelled) setLoading(false) }) return () => { cancelled = true } }, [tripId]) // ── WebSocket real-time sync ── useEffect(() => { if (!tripId) return const handler = (msg) => { if (msg.type === 'collab:note:created' && msg.note) { setNotes(prev => { if (prev.some(n => n.id === msg.note.id)) return prev return [msg.note, ...prev] }) } if (msg.type === 'collab:note:updated' && msg.note) { setNotes(prev => prev.map(n => (n.id === msg.note.id ? { ...n, ...msg.note } : n)) ) } if (msg.type === 'collab:note:deleted') { const deletedId = msg.noteId || msg.id if (deletedId) { setNotes(prev => prev.filter(n => n.id !== deletedId)) } } } addListener(handler) return () => removeListener(handler) }, [tripId]) // ── Actions ── const handleCreateNote = useCallback(async (data) => { const pendingFiles = data._pendingFiles || [] delete data._pendingFiles let created try { created = await collabApi.createNote(tripId, data) } catch (err) { toast.error(t('common.error')) throw err } if (created) { const note = created.note || created // Upload pending files if (pendingFiles.length > 0 && note.id) { for (const file of pendingFiles) { const fd = new FormData() fd.append('file', file) try { await collabApi.uploadNoteFile(tripId, note.id, fd) } catch (err) { console.error('Failed to upload note attachment:', err); toast.error(t('common.error')) } } // Reload note with attachments const fresh = await collabApi.getNotes(tripId) if (fresh?.notes) setNotes(fresh.notes) window.dispatchEvent(new Event('collab-files-changed')) return } setNotes(prev => { if (prev.some(n => n.id === note.id)) return prev return [note, ...prev] }) } }, [tripId, toast, t]) const handleUpdateNote = useCallback(async (noteId, data) => { let result try { result = await collabApi.updateNote(tripId, noteId, data) } catch (err) { toast.error(t('common.error')) throw err } const updated = result?.note || result if (updated) { setNotes(prev => prev.map(n => (n.id === noteId ? { ...n, ...updated } : n)) ) } }, [tripId, toast, t]) const saveCategoryColors = useCallback(async (newMap) => { // Update notes with changed colors for (const [cat, color] of Object.entries(newMap)) { const notesInCat = notes.filter(n => n.category === cat) if (notesInCat.length > 0 && categoryColors[cat] !== color) { for (const n of notesInCat) { await handleUpdateNote(n.id, { color }) } } } // Save all categories (including empty ones) to localStorage const emptyCats = {} for (const [cat, color] of Object.entries(newMap)) { if (!notes.some(n => n.category === cat)) { emptyCats[cat] = color } } saveEmptyCategories(emptyCats) }, [categoryColors, notes, handleUpdateNote]) const handleEditSubmit = useCallback(async (data) => { if (!editingNote) return const pendingFiles = data._pendingFiles || [] delete data._pendingFiles await handleUpdateNote(editingNote.id, data) if (pendingFiles.length > 0) { for (const file of pendingFiles) { const fd = new FormData() fd.append('file', file) try { await collabApi.uploadNoteFile(tripId, editingNote.id, fd) } catch { toast.error(t('common.error')) } } const fresh = await collabApi.getNotes(tripId) if (fresh?.notes) setNotes(fresh.notes) window.dispatchEvent(new Event('collab-files-changed')) } }, [editingNote, tripId, handleUpdateNote, toast, t]) const handleDeleteNoteFile = useCallback(async (noteId, fileId) => { try { await collabApi.deleteNoteFile(tripId, noteId, fileId) } catch { toast.error(t('common.error')) } window.dispatchEvent(new Event('collab-files-changed')) }, [tripId, toast, t]) const handleDeleteNote = useCallback(async (noteId) => { try { await collabApi.deleteNote(tripId, noteId) } catch (err) { toast.error(t('common.error')) throw err } setNotes(prev => prev.filter(n => n.id !== noteId)) window.dispatchEvent(new Event('collab-files-changed')) }, [tripId, toast, t]) // ── Derived data ── const categories = [...new Set(notes.map(n => n.category).filter(Boolean))] const sortedNotes = [...notes] .filter(n => activeCategory === null || n.category === activeCategory) .sort((a, b) => { if (a.pinned && !b.pinned) return -1 if (!a.pinned && b.pinned) return 1 const tA = new Date(a.updated_at || a.created_at || 0).getTime() const tB = new Date(b.updated_at || b.created_at || 0).getTime() return tB - tA }) return { tripId, currentUser, t, canEdit, notes, loading, showNewModal, setShowNewModal, editingNote, setEditingNote, viewingNote, setViewingNote, previewFile, setPreviewFile, showSettings, setShowSettings, activeCategory, setActiveCategory, categoryColors, getCategoryColor, handleCreateNote, handleUpdateNote, saveCategoryColors, handleEditSubmit, handleDeleteNoteFile, handleDeleteNote, categories, sortedNotes, pendingDeleteNoteId, setPendingDeleteNoteId, } } type NotesState = ReturnType function CollabNotesLoading({ t }: NotesState) { return (

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

) } function CollabNotesHeader({ t, canEdit, setShowSettings, setShowNewModal }: NotesState) { return (

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

{canEdit && } {canEdit && }
) } function CollabCategoryPills({ categories, activeCategory, setActiveCategory, t }: NotesState) { return (
{categories.map(cat => ( ))}
) } function CollabNotesGrid(S: NotesState) { const { sortedNotes, currentUser, canEdit, handleUpdateNote, setPendingDeleteNoteId, setEditingNote, setViewingNote, setPreviewFile, getCategoryColor, tripId, t, } = S return (
{sortedNotes.length === 0 ? ( /* ── Empty state ── */
{t('collab.notes.empty')}
{t('collab.notes.emptyDesc') || 'Create a note to get started'}
) : ( /* ── Notes grid — 2 columns ── */
{sortedNotes.map(note => ( ))}
)}
) } function ViewNoteModal(S: NotesState) { const { viewingNote, setViewingNote, canEdit, setEditingNote, getCategoryColor, t, setPreviewFile } = S if (!viewingNote) return null return ReactDOM.createPortal(
setViewingNote(null)} >
e.stopPropagation()} >
{viewingNote.title}
{viewingNote.category && ( {viewingNote.category} )}
{canEdit && }
{viewingNote.content || ''} {(viewingNote.attachments || []).length > 0 && (
{t('files.title')}
{(viewingNote.attachments || []).map(a => { const isImage = a.mime_type?.startsWith('image/') const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?' return (
{isImage ? ( setPreviewFile(a)} onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }} onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }} /> ) : (
setPreviewFile(a)} style={{ width: 64, height: 64, borderRadius: 8, cursor: 'pointer', background: a.mime_type === 'application/pdf' ? '#ef44441a' : 'var(--bg-secondary)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1, transition: 'transform 0.12s, box-shadow 0.12s', }} onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }} onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}> {ext}
)} {a.original_name}
) })}
)}
, document.body ) } export default function CollabNotes(props: CollabNotesProps) { const S = useCollabNotes(props) const { loading, tripId, t, categories, categoryColors, getCategoryColor, notes, viewingNote, showNewModal, editingNote, previewFile, showSettings, setShowNewModal, setEditingNote, setPreviewFile, setShowSettings, handleCreateNote, handleEditSubmit, handleDeleteNoteFile, saveCategoryColors, handleUpdateNote, handleDeleteNote, pendingDeleteNoteId, setPendingDeleteNoteId, } = S if (loading) return return (
{categories.length > 0 && } {viewingNote && } {showNewModal && ( setShowNewModal(false)} onSubmit={handleCreateNote} existingCategories={categories} categoryColors={categoryColors} getCategoryColor={getCategoryColor} t={t} /> )} {editingNote && ( setEditingNote(null)} onSubmit={handleEditSubmit} onDeleteFile={handleDeleteNoteFile} existingCategories={categories} categoryColors={categoryColors} getCategoryColor={getCategoryColor} t={t} /> )} setPreviewFile(null)} /> {showSettings && ( setShowSettings(false)} categories={categories} categoryColors={categoryColors} onSave={saveCategoryColors} onRenameCategory={async (oldName, newName) => { // Update all notes with this category in DB const toUpdate = notes.filter(n => n.category === oldName) for (const n of toUpdate) { await handleUpdateNote(n.id, { category: newName }) } }} t={t} /> )} {/* Confirm: delete a collab note — guards against accidental deletion */} setPendingDeleteNoteId(null)} onConfirm={() => { if (pendingDeleteNoteId !== null) handleDeleteNote(pendingDeleteNoteId) }} title={t('collab.notes.confirmDeleteTitle')} message={t('collab.notes.confirmDeleteBody')} />
) }