Files
TREK/client/src/components/Collab/CollabNotes.tsx
T
Maurice 49b3af8b0d feat: optimize routes around accommodation, confirm note deletions (#1123)
Optimize day routes around the accommodation

When a day has an accommodation set, the route optimizer now treats it as
the day's home base: it optimizes a loop that leaves the hotel and returns
to it, so the stop nearest the hotel comes first. On a transfer day -
checking out of one hotel and into another - the route runs from the first
hotel to the second instead.

The optimizer also gained a 2-opt pass on top of the nearest-neighbor
ordering, which removes the crossings the greedy pass used to leave behind.
A new display setting ("optimize route from accommodation", on by default)
lets you turn the anchoring off.

Confirm before deleting notes

Deleting a plan note or a collab note now asks for confirmation first. On
phones and tablets the edit and delete icons sit close together and were
easy to mis-tap, which deleted notes with no way back.
2026-06-07 12:52:06 +02:00

546 lines
23 KiB
TypeScript

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<CollabNote | null>(null)
const [previewFile, setPreviewFile] = useState(null)
const [showSettings, setShowSettings] = useState(false)
const [activeCategory, setActiveCategory] = useState(null)
const [pendingDeleteNoteId, setPendingDeleteNoteId] = useState<number | null>(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<typeof useCollabNotes>
function CollabNotesLoading({ t }: NotesState) {
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}>
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border-faint)' }}>
<h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', margin: 0, fontFamily: FONT }}>
{t('collab.notes.title')}
</h3>
</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{
width: 20, height: 20, border: '2px solid var(--border-primary)',
borderTopColor: 'var(--text-primary)', borderRadius: '50%',
animation: 'collab-notes-spin 0.7s linear infinite',
}} />
<style>{`@keyframes collab-notes-spin { to { transform: rotate(360deg) } }`}</style>
</div>
</div>
)
}
function CollabNotesHeader({ t, canEdit, setShowSettings, setShowNewModal }: NotesState) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}>
<h3 style={{
fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', margin: 0, fontFamily: FONT,
letterSpacing: 0.3, textTransform: 'uppercase', display: 'flex', alignItems: 'center', gap: 7,
}}>
<StickyNote size={14} color="var(--text-faint)" />
{t('collab.notes.title')}
</h3>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
{canEdit && <button onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings') || 'Categories'}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 8, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', transition: 'color 0.12s' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Settings size={14} />
</button>}
{canEdit && <button onClick={() => setShowNewModal(true)}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px', background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600, fontFamily: FONT, border: 'none', cursor: 'pointer', whiteSpace: 'nowrap' }}>
<Plus size={12} />
{t('collab.notes.new')}
</button>}
</div>
</div>
)
}
function CollabCategoryPills({ categories, activeCategory, setActiveCategory, t }: NotesState) {
return (
<div style={{ display: 'flex', gap: 4, padding: '8px 12px 0', overflowX: 'auto', flexShrink: 0 }}>
<button
onClick={() => setActiveCategory(null)}
style={{
flexShrink: 0, borderRadius: 99, padding: '3px 10px', fontSize: 10, fontWeight: 600, fontFamily: FONT,
border: activeCategory === null ? '1px solid var(--accent)' : '1px solid var(--border-faint)',
background: activeCategory === null ? 'var(--accent)' : 'transparent',
color: activeCategory === null ? 'var(--accent-text)' : 'var(--text-secondary)',
cursor: 'pointer', whiteSpace: 'nowrap', textTransform: 'uppercase', letterSpacing: '0.03em',
}}
>
{t('collab.notes.all')}
</button>
{categories.map(cat => (
<button
key={cat}
onClick={() => setActiveCategory(prev => prev === cat ? null : cat)}
style={{
flexShrink: 0, borderRadius: 99, padding: '3px 10px', fontSize: 10, fontWeight: 600, fontFamily: FONT,
border: activeCategory === cat ? '1px solid var(--accent)' : '1px solid var(--border-faint)',
background: activeCategory === cat ? 'var(--accent)' : 'transparent',
color: activeCategory === cat ? 'var(--accent-text)' : 'var(--text-secondary)',
cursor: 'pointer', whiteSpace: 'nowrap', textTransform: 'uppercase', letterSpacing: '0.03em',
}}
>
{cat}
</button>
))}
</div>
)
}
function CollabNotesGrid(S: NotesState) {
const {
sortedNotes, currentUser, canEdit, handleUpdateNote, setPendingDeleteNoteId,
setEditingNote, setViewingNote, setPreviewFile, getCategoryColor, tripId, t,
} = S
return (
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
{sortedNotes.length === 0 ? (
/* ── Empty state ── */
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
padding: '48px 20px', textAlign: 'center', height: '100%',
}}>
<Pencil size={36} color="var(--text-faint)" style={{ marginBottom: 12 }} />
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4, fontFamily: FONT }}>
{t('collab.notes.empty')}
</div>
<div style={{ fontSize: 12, color: 'var(--text-faint)', fontFamily: FONT }}>
{t('collab.notes.emptyDesc') || 'Create a note to get started'}
</div>
</div>
) : (
/* ── Notes grid — 2 columns ── */
<div style={{
display: 'grid',
gridTemplateColumns: window.innerWidth < 768 ? '1fr' : 'repeat(2, 1fr)',
gap: 8,
}}>
{sortedNotes.map(note => (
<NoteCard
key={note.id}
note={note}
currentUser={currentUser}
canEdit={canEdit}
onUpdate={handleUpdateNote}
onDelete={setPendingDeleteNoteId}
onEdit={setEditingNote}
onView={setViewingNote}
onPreviewFile={setPreviewFile}
getCategoryColor={getCategoryColor}
tripId={tripId}
t={t}
/>
))}
</div>
)}
</div>
)
}
function ViewNoteModal(S: NotesState) {
const { viewingNote, setViewingNote, canEdit, setEditingNote, getCategoryColor, t, setPreviewFile } = S
if (!viewingNote) return null
return ReactDOM.createPortal(
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 10000, padding: 16,
}}
onClick={() => setViewingNote(null)}
>
<div
style={{
background: 'var(--bg-card)', borderRadius: 16,
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
width: 'min(700px, calc(100vw - 32px))', maxHeight: '80vh',
overflow: 'hidden', display: 'flex', flexDirection: 'column',
}}
onClick={e => e.stopPropagation()}
>
<div style={{
padding: '16px 20px 12px', borderBottom: '1px solid var(--border-primary)',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 17, fontWeight: 600, color: 'var(--text-primary)' }}>{viewingNote.title}</div>
{viewingNote.category && (
<span style={{
display: 'inline-block', marginTop: 4, fontSize: 10, fontWeight: 600,
color: getCategoryColor(viewingNote.category),
background: `${getCategoryColor(viewingNote.category)}18`,
padding: '2px 8px', borderRadius: 6,
}}>{viewingNote.category}</span>
)}
</div>
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
{canEdit && <button onClick={() => { setViewingNote(null); setEditingNote(viewingNote) }}
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={16} />
</button>}
<button onClick={() => setViewingNote(null)}
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<X size={18} />
</button>
</div>
</div>
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 14, color: 'var(--text-primary)', lineHeight: 1.7 }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{viewingNote.content || ''}</Markdown>
{(viewingNote.attachments || []).length > 0 && (
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border-primary)' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{t('files.title')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{(viewingNote.attachments || []).map(a => {
const isImage = a.mime_type?.startsWith('image/')
const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?'
return (
<div key={a.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, maxWidth: 72 }}>
{isImage ? (
<AuthedImg src={a.url} alt={a.original_name}
style={{ width: 64, height: 64, objectFit: 'cover', borderRadius: 8, cursor: 'pointer', transition: 'transform 0.12s, box-shadow 0.12s' }}
onClick={() => 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' }} />
) : (
<div title={a.original_name} onClick={() => 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' }}>
<span style={{ fontSize: 10, fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
</div>
)}
<span style={{ fontSize: 9, color: 'var(--text-faint)', textAlign: 'center', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', width: '100%' }}>{a.original_name}</span>
</div>
)
})}
</div>
</div>
)}
</div>
</div>
</div>,
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 <CollabNotesLoading {...S} />
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}>
<CollabNotesHeader {...S} />
{categories.length > 0 && <CollabCategoryPills {...S} />}
<CollabNotesGrid {...S} />
{viewingNote && <ViewNoteModal {...S} />}
{showNewModal && (
<NoteFormModal
note={null}
tripId={tripId}
onClose={() => setShowNewModal(false)}
onSubmit={handleCreateNote}
existingCategories={categories}
categoryColors={categoryColors}
getCategoryColor={getCategoryColor}
t={t}
/>
)}
{editingNote && (
<NoteFormModal
note={editingNote}
tripId={tripId}
onClose={() => setEditingNote(null)}
onSubmit={handleEditSubmit}
onDeleteFile={handleDeleteNoteFile}
existingCategories={categories}
categoryColors={categoryColors}
getCategoryColor={getCategoryColor}
t={t}
/>
)}
<FilePreviewPortal file={previewFile} onClose={() => setPreviewFile(null)} />
{showSettings && (
<CategorySettingsModal
onClose={() => 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 */}
<ConfirmDialog
isOpen={pendingDeleteNoteId !== null}
onClose={() => setPendingDeleteNoteId(null)}
onConfirm={() => { if (pendingDeleteNoteId !== null) handleDeleteNote(pendingDeleteNoteId) }}
title={t('collab.notes.confirmDeleteTitle')}
message={t('collab.notes.confirmDeleteBody')}
/>
</div>
)
}