mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
49b3af8b0d
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.
199 lines
11 KiB
TypeScript
199 lines
11 KiB
TypeScript
import { useState, useCallback } from 'react'
|
|
import Markdown from 'react-markdown'
|
|
import remarkGfm from 'remark-gfm'
|
|
import remarkBreaks from 'remark-breaks'
|
|
import { Trash2, Pin, PinOff, Pencil, Maximize2 } from 'lucide-react'
|
|
import { FONT } from './CollabNotes.constants'
|
|
import { AuthedImg } from './CollabNotesAuthedImg'
|
|
import { UserAvatar } from './CollabNotesUserAvatar'
|
|
import { WebsiteThumbnail } from './CollabNotesWebsiteThumbnail'
|
|
import type { CollabNote, NoteFile } from './CollabNotes.types'
|
|
import type { User } from '../../types'
|
|
|
|
// ── Note Card ───────────────────────────────────────────────────────────────
|
|
interface NoteCardProps {
|
|
note: CollabNote
|
|
currentUser: User
|
|
canEdit: boolean
|
|
onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void>
|
|
onDelete: (noteId: number) => void
|
|
onEdit: (note: CollabNote) => void
|
|
onView: (note: CollabNote) => void
|
|
onPreviewFile: (file: NoteFile) => void
|
|
getCategoryColor: (category: string) => string
|
|
tripId: number
|
|
t: (key: string) => string
|
|
}
|
|
|
|
export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onView, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) {
|
|
const [hovered, setHovered] = useState(false)
|
|
|
|
const author = note.author || note.user || { username: note.username, avatar: note.avatar_url || (note.avatar ? `/uploads/avatars/${note.avatar}` : null) }
|
|
const color = getCategoryColor ? getCategoryColor(note.category) : (note.color || '#6366f1')
|
|
|
|
const handleTogglePin = useCallback(() => {
|
|
onUpdate(note.id, { pinned: !note.pinned })
|
|
}, [note.id, note.pinned, onUpdate])
|
|
|
|
const handleDelete = useCallback(() => {
|
|
onDelete(note.id)
|
|
}, [note.id, onDelete])
|
|
|
|
return (
|
|
<div
|
|
onMouseEnter={() => setHovered(true)}
|
|
onMouseLeave={() => setHovered(false)}
|
|
style={{
|
|
position: 'relative',
|
|
borderRadius: 12,
|
|
overflow: 'hidden',
|
|
border: `1px solid ${note.pinned ? color + '40' : color + '25'}`,
|
|
background: note.pinned ? `${color}08` : 'var(--bg-card)',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
fontFamily: FONT,
|
|
transition: 'transform 0.12s, box-shadow 0.12s',
|
|
...(hovered ? { transform: 'translateY(-1px)', boxShadow: '0 4px 16px rgba(0,0,0,0.08)' } : {}),
|
|
}}
|
|
>
|
|
{/* Header bar — like reservation cards */}
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', gap: 6, padding: '7px 10px',
|
|
background: `${color}0d`,
|
|
}}>
|
|
{!!note.pinned && <Pin size={9} color={color} style={{ flexShrink: 0 }} />}
|
|
<span style={{ display: 'flex', alignItems: 'center', gap: 5, overflow: 'hidden', flex: 1, minWidth: 0 }}>
|
|
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{note.title}
|
|
</span>
|
|
{note.category && (
|
|
<span style={{ fontSize: 8, fontWeight: 600, color, background: `${color}18`, padding: '2px 6px', borderRadius: 99, flexShrink: 0, letterSpacing: '0.02em', textTransform: 'uppercase' }}>
|
|
{note.category}
|
|
</span>
|
|
)}
|
|
</span>
|
|
|
|
{/* Hover actions in header */}
|
|
{(
|
|
<div style={{
|
|
display: 'flex', gap: 2,
|
|
}}>
|
|
{note.content && (
|
|
<button onClick={() => onView?.(note)} title={t('collab.notes.expand') || 'Expand'}
|
|
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
|
<Maximize2 size={10} />
|
|
</button>
|
|
)}
|
|
{canEdit && <button onClick={handleTogglePin} title={note.pinned ? t('collab.notes.unpin') : t('collab.notes.pin')}
|
|
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
|
onMouseEnter={e => e.currentTarget.style.color = color}
|
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
|
{note.pinned ? <PinOff size={10} /> : <Pin size={10} />}
|
|
</button>}
|
|
{canEdit && <button onClick={() => onEdit?.(note)} title={t('collab.notes.edit')}
|
|
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
|
<Pencil size={10} />
|
|
</button>}
|
|
{canEdit && <button onClick={handleDelete} title={t('collab.notes.delete')}
|
|
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
|
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
|
<Trash2 size={10} />
|
|
</button>}
|
|
<div style={{ width: 1, height: 12, background: 'var(--border-faint)', flexShrink: 0, marginLeft: 1, marginRight: 1 }} />
|
|
{/* Author avatar */}
|
|
<div style={{ position: 'relative', flexShrink: 0 }}
|
|
onMouseEnter={e => { const tip = e.currentTarget.querySelector<HTMLElement>('[data-tip]'); if (tip) tip.style.opacity = '1' }}
|
|
onMouseLeave={e => { const tip = e.currentTarget.querySelector<HTMLElement>('[data-tip]'); if (tip) tip.style.opacity = '0' }}>
|
|
<UserAvatar user={author} size={16} />
|
|
<div data-tip style={{
|
|
position: 'absolute', bottom: '100%', left: '50%', transform: 'translateX(-50%)',
|
|
marginBottom: 6, pointerEvents: 'none', opacity: 0, transition: 'opacity 0.12s',
|
|
whiteSpace: 'nowrap', zIndex: 10,
|
|
background: 'var(--bg-card)', color: 'var(--text-primary)',
|
|
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint)',
|
|
}}>
|
|
{author.username}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Card body */}
|
|
<div style={{
|
|
padding: '8px 12px 10px',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 4,
|
|
flex: 1,
|
|
}}>
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
{note.content && (
|
|
<div className="collab-note-md" style={{
|
|
fontSize: 11.5, color: 'var(--text-muted)', lineHeight: 1.5, margin: 0,
|
|
maxHeight: '4.5em', overflow: 'hidden',
|
|
wordBreak: 'break-word', fontFamily: FONT,
|
|
}}>
|
|
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{note.content}</Markdown>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{/* Right: website + attachment thumbnails */}
|
|
{(note.website || (note.attachments?.length ?? 0) > 0) && (
|
|
<div style={{ display: 'flex', gap: 6, flexShrink: 0, alignItems: 'flex-start' }}>
|
|
{/* Website */}
|
|
{note.website && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
|
<span style={{ fontSize: 7, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3 }}>Link</span>
|
|
<WebsiteThumbnail url={note.website} tripId={tripId} color={color} />
|
|
</div>
|
|
)}
|
|
{/* Files */}
|
|
{(note.attachments || []).length > 0 && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
|
<span style={{ fontSize: 7, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3 }}>{t('files.title')}</span>
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
{(note.attachments || []).slice(0, note.website ? 1 : 2).map(a => {
|
|
const isImage = a.mime_type?.startsWith('image/')
|
|
const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
|
return isImage ? (
|
|
<AuthedImg key={a.id} src={a.url} alt={a.original_name}
|
|
style={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 8, cursor: 'pointer', transition: 'transform 0.12s, box-shadow 0.12s' }}
|
|
onClick={() => onPreviewFile?.(a)}
|
|
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; 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 key={a.id} title={a.original_name} onClick={() => onPreviewFile?.(a)}
|
|
style={{
|
|
width: 48, height: 48, 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.08)'; 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: 9, fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
|
|
</div>
|
|
)
|
|
})}
|
|
{(note.attachments?.length || 0) > (note.website ? 1 : 2) && (
|
|
<span style={{ fontSize: 8, color: 'var(--text-faint)', textAlign: 'center' }}>+{(note.attachments?.length || 0) - (note.website ? 1 : 2)}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|