import ReactDOM from 'react-dom' import { useState, useEffect, useCallback, useRef, useMemo } from 'react' import DOM from 'react-dom' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2 } from 'lucide-react' import { collabApi } from '../../api/client' import { addListener, removeListener } from '../../api/websocket' import { useTranslation } from '../../i18n' import type { User } from '../../types' interface NoteFile { id: number filename: string original_name: string mime_type: string url?: string } interface CollabNote { id: number trip_id: number title: string content: string category: string website: string | null pinned: boolean color: string | null username: string avatar_url: string | null avatar: string | null user_id: number created_at: string author?: { username: string; avatar: string | null } user?: { username: string; avatar: string | null } files?: NoteFile[] } interface NoteAuthor { username: string avatar?: string | null } const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" // ── Website Thumbnail (fetches OG image) ──────────────────────────────────── const ogCache = {} interface WebsiteThumbnailProps { url: string tripId: number color: string } function WebsiteThumbnail({ url, tripId, color }: WebsiteThumbnailProps) { const [data, setData] = useState(ogCache[url] || null) const [failed, setFailed] = useState(false) useEffect(() => { if (ogCache[url]) { setData(ogCache[url]); return } collabApi.linkPreview(tripId, url).then(d => { ogCache[url] = d; setData(d) }).catch(() => setFailed(true)) }, [url, tripId]) const domain = (() => { try { return new URL(url).hostname.replace('www.', '') } catch { return 'link' } })() return ( { 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' }}> {data?.image && !failed ? ( setFailed(true)} /> ) : ( <> {domain} )} ) } // ── File Preview Portal ───────────────────────────────────────────────────── interface FilePreviewPortalProps { file: NoteFile | null onClose: () => void } function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) { if (!file) return null const url = file.url || `/uploads/${file.filename}` const isImage = file.mime_type?.startsWith('image/') const isPdf = file.mime_type === 'application/pdf' const isTxt = file.mime_type?.startsWith('text/') return ReactDOM.createPortal(
{isImage ? ( /* Image lightbox — floating controls */
e.stopPropagation()}> {file.original_name}
{file.original_name}
) : ( /* Document viewer — card with header */
e.stopPropagation()}>
{file.original_name}
{(isPdf || isTxt) ? (

Download

) : (
Download {file.original_name}
)}
)}
, document.body ) } const NOTE_COLORS = [ { value: '#6366f1', label: 'Indigo' }, { value: '#ef4444', label: 'Red' }, { value: '#f59e0b', label: 'Amber' }, { value: '#10b981', label: 'Emerald' }, { value: '#3b82f6', label: 'Blue' }, { value: '#8b5cf6', label: 'Violet' }, ] const formatTimestamp = (ts, t, locale) => { if (!ts) return '' const d = new Date(ts.endsWith?.('Z') ? ts : ts + 'Z') const now = new Date() const diffMs = now - d const diffMins = Math.floor(diffMs / 60000) if (diffMins < 1) return t('collab.chat.justNow') || 'just now' if (diffMins < 60) return t('collab.chat.minutesAgo', { n: diffMins }) || `${diffMins}m ago` const diffHrs = Math.floor(diffMins / 60) if (diffHrs < 24) return t('collab.chat.hoursAgo', { n: diffHrs }) || `${diffHrs}h ago` const diffDays = Math.floor(diffHrs / 24) if (diffDays < 7) return t('collab.notes.daysAgo', { n: diffDays }) || `${diffDays}d ago` return d.toLocaleDateString(locale || undefined, { month: 'short', day: 'numeric' }) } // ── Avatar ────────────────────────────────────────────────────────────────── interface UserAvatarProps { user: NoteAuthor | null size?: number } function UserAvatar({ user, size = 14 }: UserAvatarProps) { if (!user) return null if (user.avatar) { return ( {user.username} ) } const initials = (user.username || '?').slice(0, 1) return (
{initials}
) } // ── New Note Modal (portal to body) ───────────────────────────────────────── interface NoteFormModalProps { onClose: () => void onSubmit: (data: { title: string; content: string; category: string; website: string; files?: File[] }) => Promise onDeleteFile: (noteId: number, fileId: number) => Promise existingCategories: string[] categoryColors: Record getCategoryColor: (category: string) => string note: CollabNote | null tripId: number t: (key: string) => string } function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, categoryColors, getCategoryColor, note, tripId, t }: NoteFormModalProps) { const isEdit = !!note const allCategories = [...new Set([...existingCategories, ...Object.keys(categoryColors || {})])].filter(Boolean) const [title, setTitle] = useState(note?.title || '') const [content, setContent] = useState(note?.content || '') const [category, setCategory] = useState(note?.category || allCategories[0] || '') const [website, setWebsite] = useState(note?.website || '') const [pendingFiles, setPendingFiles] = useState([]) const [existingAttachments, setExistingAttachments] = useState(note?.attachments || []) const [submitting, setSubmitting] = useState(false) const fileRef = useRef(null) const finalCategory = category const handleSubmit = async (e) => { e.preventDefault() if (!title.trim()) return setSubmitting(true) try { await onSubmit({ title: title.trim(), content: content.trim(), category: finalCategory || null, color: getCategoryColor(finalCategory), website: website.trim() || null, _pendingFiles: pendingFiles, }) onClose() } catch { } finally { setSubmitting(false) } } const handleDeleteAttachment = async (fileId) => { if (onDeleteFile && note) { await onDeleteFile(note.id, fileId) setExistingAttachments(prev => prev.filter(a => a.id !== fileId)) } } const canSubmit = title.trim() && !submitting return ReactDOM.createPortal(
e.stopPropagation()} onPaste={e => { const items = e.clipboardData?.items if (!items) return for (const item of Array.from(items)) { if (item.type.startsWith('image/') || item.type === 'application/pdf') { e.preventDefault() const file = item.getAsFile() if (file) setPendingFiles(prev => [...prev, file]) return } } }} onSubmit={handleSubmit} > {/* Modal header */}

{isEdit ? t('collab.notes.edit') : t('collab.notes.new')}

{/* Modal body */}
{/* Title */}
{t('collab.notes.title')}
setTitle(e.target.value)} placeholder={t('collab.notes.titlePlaceholder')} style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 13, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box', }} />
{/* Content */}
{t('collab.notes.contentPlaceholder')}