Files
TREK/client/src/components/Collab/CollabNotes.tsx
T
Maurice fc7d8b5d12 Migrate TREK 3 to NestJS + React 19 with a shared Zod contract layer
Brownfield strangler migration of the backend onto NestJS modules
(auth, trips, days, places, assignments, packing, todo, budget,
reservations, collab, files, photos, journey, share, settings, backup,
oidc, oauth, admin, atlas, vacay, weather, airports, maps, categories,
tags, notifications, system-notices) served through a per-prefix
dispatcher, keeping the existing SQLite/better-sqlite3 DB and JWT
httpOnly cookie auth, with behavioural parity for every route.

Client: React 19 upgrade, "page = wiring container + data hook"
pattern across all pages, per-domain Zustand stores bound to
@trek/shared contracts, and decomposition of the large components
(DayPlanSidebar, PackingListPanel, CollabNotes, FileManager,
MemoriesPanel, PlacesSidebar, CollabChat, SystemNoticeModal,
BudgetPanel, PlaceFormModal, ...) into focused render units backed by
in-file hooks.

Apply the shared global request pipeline (helmet/CSP, CORS, HSTS,
forced HTTPS, the global MFA policy and request logging) to the NestJS
instance as well, so a migrated route is protected identically to the
legacy fallback rather than bypassing it.
2026-05-30 02:39:26 +02:00

1396 lines
62 KiB
TypeScript

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 remarkBreaks from 'remark-breaks'
import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2, Loader2 } from 'lucide-react'
import { collabApi } from '../../api/client'
import { getAuthUrl } from '../../api/authUrl'
import { openFile } from '../../utils/fileDownload'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
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 (
<a href={url} target="_blank" rel="noopener noreferrer" title={data?.title || url}
style={{
width: 48, height: 48, borderRadius: 8, cursor: 'pointer', overflow: 'hidden',
background: data?.image ? 'none' : 'var(--bg-tertiary)', border: 'none',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 2,
textDecoration: 'none', transition: 'transform 0.12s, box-shadow 0.12s', flexShrink: 0,
}}
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' }}>
{data?.image && !failed ? (
<img src={data.image} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} onError={() => setFailed(true)} />
) : (
<>
<ExternalLink size={14} color="var(--text-muted)" />
<span style={{ fontSize: 7, fontWeight: 600, color: 'var(--text-muted)', maxWidth: 42, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center' }}>
{domain}
</span>
</>
)}
</a>
)
}
// ── File Preview Portal ─────────────────────────────────────────────────────
interface FilePreviewPortalProps {
file: NoteFile | null
onClose: () => void
}
function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
const [authUrl, setAuthUrl] = useState('')
const rawUrl = file?.url || ''
useEffect(() => {
setAuthUrl('')
if (!rawUrl) return
getAuthUrl(rawUrl, 'download').then(setAuthUrl)
}, [rawUrl])
if (!file) return null
const isImage = file.mime_type?.startsWith('image/')
const isPdf = file.mime_type === 'application/pdf'
const isTxt = file.mime_type?.startsWith('text/')
const openInNewTab = () => openFile(rawUrl).catch(() => {})
return ReactDOM.createPortal(
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} onClick={onClose}>
{isImage ? (
/* Image lightbox — floating controls */
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
{authUrl
? <img src={authUrl} alt={file.original_name} style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} />
: <Loader2 size={32} className="animate-spin" style={{ color: 'rgba(255,255,255,0.5)' }} />
}
<div style={{ position: 'absolute', top: -36, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{file.original_name}</span>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><ExternalLink size={15} /></button>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><X size={17} /></button>
</div>
</div>
</div>
) : (
/* Document viewer — card with header */
<div style={{ width: '100%', maxWidth: 950, height: '94vh', display: 'flex', flexDirection: 'column', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{file.original_name}</span>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 3, fontSize: 11, color: 'var(--text-muted)', padding: 0 }}><ExternalLink size={13} /></button>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={18} /></button>
</div>
</div>
{(isPdf || isTxt) ? (
<object data={authUrl ? `${authUrl}#view=FitH` : ''} type={file.mime_type} style={{ flex: 1, width: '100%', border: 'none', background: '#fff' }} title={file.original_name}>
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14, padding: 0 }}>Download</button>
</p>
</object>
) : (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14, padding: 0 }}>Download {file.original_name}</button>
</div>
)}
</div>
)}
</div>,
document.body
)
}
function AuthedImg({ src, style, onClick, onMouseEnter, onMouseLeave, alt }: { src: string; style?: React.CSSProperties; onClick?: () => void; onMouseEnter?: React.MouseEventHandler<HTMLImageElement>; onMouseLeave?: React.MouseEventHandler<HTMLImageElement>; alt?: string }) {
const [authSrc, setAuthSrc] = useState('')
useEffect(() => {
getAuthUrl(src, 'download').then(setAuthSrc)
}, [src])
return authSrc ? <img src={authSrc} alt={alt} style={style} onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} /> : null
}
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 (
<img
src={user.avatar}
alt={user.username}
style={{
width: size,
height: size,
borderRadius: '50%',
objectFit: 'cover',
flexShrink: 0,
background: 'var(--bg-tertiary)',
}}
/>
)
}
const initials = (user.username || '?').slice(0, 1)
return (
<div style={{
width: size,
height: size,
borderRadius: '50%',
background: 'var(--bg-tertiary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: size * 0.45,
fontWeight: 600,
color: 'var(--text-faint)',
flexShrink: 0,
textTransform: 'uppercase',
fontFamily: FONT,
}}>
{initials}
</div>
)
}
// ── New Note Modal (portal to body) ─────────────────────────────────────────
interface NoteFormModalProps {
onClose: () => void
onSubmit: (data: { title: string; content: string; category: string; website: string; files?: File[] }) => Promise<void>
onDeleteFile?: (noteId: number, fileId: number) => Promise<void>
existingCategories: string[]
categoryColors: Record<string, string>
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 can = useCanDo()
const tripObj = useTripStore((s) => s.trip)
const canUploadFiles = can('file_upload', tripObj)
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(
<div
style={{
position: 'fixed',
inset: 0,
background: 'var(--overlay-bg, rgba(0,0,0,0.35))',
backdropFilter: 'blur(6px)',
WebkitBackdropFilter: 'blur(6px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
padding: 16,
fontFamily: FONT,
}}
>
<form
style={{
background: 'var(--bg-card)',
borderRadius: 16,
width: '100%',
maxWidth: 400,
maxHeight: '90vh',
overflow: 'auto',
border: '1px solid var(--border-faint)',
}}
onClick={e => e.stopPropagation()}
onPaste={e => {
if (!canUploadFiles) return
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 */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '14px 16px 12px',
borderBottom: '1px solid var(--border-faint)',
}}>
<h3 style={{
fontSize: 14,
fontWeight: 700,
color: 'var(--text-primary)',
margin: 0,
fontFamily: FONT,
}}>
{isEdit ? t('collab.notes.edit') : t('collab.notes.new')}
</h3>
<button
type="button"
onClick={onClose}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--text-faint)',
padding: 2,
borderRadius: 6,
display: 'flex',
}}
>
<X size={16} />
</button>
</div>
{/* Modal body */}
<div style={{
padding: '14px 16px 16px',
display: 'flex',
flexDirection: 'column',
gap: 12,
}}>
{/* Title */}
<div>
<div style={{
fontSize: 9,
fontWeight: 600,
color: 'var(--text-faint)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: 4,
fontFamily: FONT,
}}>
{t('collab.notes.title')}
</div>
<input
autoFocus
value={title}
onChange={e => 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',
}}
/>
</div>
{/* Content */}
<div>
<div style={{
fontSize: 9,
fontWeight: 600,
color: 'var(--text-faint)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: 4,
fontFamily: FONT,
}}>
{t('collab.notes.contentPlaceholder')}
</div>
<textarea
value={content}
onChange={e => setContent(e.target.value)}
placeholder={t('collab.notes.contentPlaceholder')}
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',
resize: 'vertical',
minHeight: 180,
lineHeight: 1.5,
}}
/>
</div>
{/* Category pills */}
<div>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6, fontFamily: FONT }}>
{t('collab.notes.category')}
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{allCategories.map(cat => {
const c = getCategoryColor(cat)
const active = category === cat
return (
<button key={cat} type="button" onClick={() => setCategory(cat)}
style={{ padding: '4px 12px', borderRadius: 99, border: active ? `1.5px solid ${c}` : '1px solid var(--border-faint)', background: active ? `${c}18` : 'transparent', color: active ? c : 'var(--text-muted)', fontSize: 11, fontWeight: 600, cursor: 'pointer', fontFamily: FONT }}>
{cat}
</button>
)
})}
</div>
</div>
{/* Website */}
<div>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
{t('collab.notes.website')}
</div>
<input value={website} onChange={e => setWebsite(e.target.value)}
placeholder={t('collab.notes.websitePlaceholder')}
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' }} />
</div>
{/* File attachments */}
{canUploadFiles && <div>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
{t('collab.notes.attachFiles')}
</div>
<input ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { const files = e.target.files; if (files?.length) setPendingFiles(prev => [...prev, ...Array.from(files)]); e.target.value = '' }} />
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
{/* Existing attachments (edit mode) */}
{existingAttachments.map(a => {
const isImage = a.mime_type?.startsWith('image/')
return (
<div key={a.id} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
{isImage && <AuthedImg src={a.url} style={{ width: 18, height: 18, objectFit: 'cover', borderRadius: 3 }} />}
{(a.original_name || '').length > 20 ? a.original_name.slice(0, 17) + '...' : a.original_name}
<button type="button" onClick={() => handleDeleteAttachment(a.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ef4444', padding: 0, display: 'flex' }}>
<X size={10} />
</button>
</div>
)
})}
{/* New pending files */}
{pendingFiles.map((f, i) => (
<div key={`new-${i}`} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
{f.name.length > 20 ? f.name.slice(0, 17) + '...' : f.name}
<button type="button" onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 0, display: 'flex' }}>
<X size={10} />
</button>
</div>
))}
<button type="button" onClick={() => fileRef.current?.click()}
style={{ padding: '4px 10px', borderRadius: 8, border: '1px dashed var(--border-faint)', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', fontSize: 11, fontFamily: FONT, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<Plus size={11} /> {t('files.attach') || 'Add'}
</button>
</div>
</div>}
{/* Submit */}
<button
type="submit"
disabled={!canSubmit}
style={{
width: '100%',
borderRadius: 99,
padding: '7px 14px',
background: canSubmit ? 'var(--accent)' : 'var(--border-primary)',
color: canSubmit ? 'var(--accent-text)' : 'var(--text-faint)',
fontSize: 12,
fontWeight: 600,
fontFamily: FONT,
border: 'none',
cursor: canSubmit ? 'pointer' : 'default',
marginTop: 4,
}}
>
{submitting ? '...' : isEdit ? t('collab.notes.save') : t('collab.notes.create')}
</button>
</div>
</form>
</div>,
document.body
)
}
interface EditableCatNameProps {
name: string
onRename: (newName: string) => void
}
function EditableCatName({ name, onRename }: EditableCatNameProps) {
const [editing, setEditing] = useState(false)
const [value, setValue] = useState(name)
const inputRef = useRef(null)
useEffect(() => { if (editing && inputRef.current) { inputRef.current.focus(); inputRef.current.select() } }, [editing])
const save = () => {
setEditing(false)
if (value.trim() && value.trim() !== name) onRename(value.trim())
else setValue(name)
}
if (editing) {
return <input ref={inputRef} value={value} onChange={e => setValue(e.target.value)}
onBlur={save} onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setValue(name); setEditing(false) } }}
style={{ flex: 1, fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', border: '1px solid var(--border-primary)', borderRadius: 6, padding: '2px 8px', background: 'var(--bg-input)', fontFamily: 'inherit', outline: 'none' }} />
}
return (
<span onClick={() => { setValue(name); setEditing(true) }}
style={{ flex: 1, fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', cursor: 'pointer', padding: '2px 0' }}
title="Click to rename">
{name}
</span>
)
}
// ── Category Settings Modal ──────────────────────────────────────────────────
interface CategorySettingsModalProps {
onClose: () => void
categories: string[]
categoryColors: Record<string, string>
onSave: (colors: Record<string, string>) => void
onRenameCategory: (oldName: string, newName: string) => Promise<void>
t: (key: string) => string
}
function CategorySettingsModal({ onClose, categories, categoryColors, onSave, onRenameCategory, t }: CategorySettingsModalProps) {
const [localColors, setLocalColors] = useState({ ...categoryColors })
const [renames, setRenames] = useState({}) // { oldName: newName }
const [newCatName, setNewCatName] = useState('')
const handleColorChange = (cat, color) => {
setLocalColors(prev => ({ ...prev, [cat]: color }))
}
const handleAddCategory = () => {
if (!newCatName.trim() || localColors[newCatName.trim()]) return
setLocalColors(prev => ({ ...prev, [newCatName.trim()]: NOTE_COLORS[Object.keys(prev).length % NOTE_COLORS.length].value }))
setNewCatName('')
}
const handleRemoveCategory = (cat) => {
setLocalColors(prev => { const n = { ...prev }; delete n[cat]; return n })
}
const handleRenameCategory = (oldName, newName) => {
if (!newName.trim() || newName.trim() === oldName || localColors[newName.trim()]) return
// Track rename for saving to DB later
const originalName = Object.entries(renames).find(([, v]) => v === oldName)?.[0] || oldName
setRenames(prev => ({ ...prev, [originalName]: newName.trim() }))
setLocalColors(prev => {
const n = {}
for (const [k, v] of Object.entries(prev)) {
n[k === oldName ? newName.trim() : k] = v
}
return n
})
}
const handleSave = async () => {
// Apply renames to notes in DB
for (const [oldName, newName] of Object.entries(renames)) {
if (oldName !== newName) await onRenameCategory(oldName, newName)
}
await onSave(localColors)
onClose()
}
// Merge existing categories from notes with saved colors
const allCats = [...new Set([...categories, ...Object.keys(localColors)])]
return ReactDOM.createPortal(
<div style={{
position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))',
backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT,
}} onClick={onClose}>
<div style={{
background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 420,
maxHeight: '80vh', overflow: 'auto', border: '1px solid var(--border-faint)',
}} onClick={e => e.stopPropagation()}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
<h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>
{t('collab.notes.categorySettings') || 'Category Settings'}
</h3>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}>
<X size={16} />
</button>
</div>
{/* Categories list */}
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
{allCats.length === 0 && (
<p style={{ fontSize: 12, color: 'var(--text-faint)', textAlign: 'center', padding: 16 }}>
{t('collab.notes.noCategoriesYet') || 'No categories yet'}
</p>
)}
{allCats.map(cat => (
<div key={cat} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{/* Color swatches */}
<div style={{ display: 'flex', gap: 4 }}>
{NOTE_COLORS.map(c => (
<button key={c.value} onClick={() => handleColorChange(cat, c.value)} style={{
width: 20, height: 20, borderRadius: 6, background: c.value, border: 'none', cursor: 'pointer', padding: 0,
outline: (localColors[cat] || NOTE_COLORS[0].value) === c.value ? '2px solid var(--text-primary)' : '2px solid transparent',
outlineOffset: 1, transition: 'transform 0.1s',
transform: (localColors[cat] || NOTE_COLORS[0].value) === c.value ? 'scale(1.1)' : 'scale(1)',
}} />
))}
</div>
{/* Category name — editable */}
<EditableCatName name={cat} onRename={(newName) => handleRenameCategory(cat, newName)} />
{/* Delete */}
<button onClick={() => handleRemoveCategory(cat)} style={{
background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 3, display: 'flex',
}}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Trash2 size={13} />
</button>
</div>
))}
{/* Add new */}
<div style={{ display: 'flex', gap: 6, marginTop: 4 }}>
<input value={newCatName} onChange={e => setNewCatName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
placeholder={t('collab.notes.newCategory')}
style={{
flex: 1, 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',
}} />
<button onClick={handleAddCategory} disabled={!newCatName.trim()} style={{
background: newCatName.trim() ? 'var(--accent)' : 'var(--border-primary)', color: 'var(--accent-text)',
border: 'none', borderRadius: 10, padding: '8px 14px', cursor: newCatName.trim() ? 'pointer' : 'default',
display: 'flex', alignItems: 'center', flexShrink: 0,
}}>
<Plus size={14} />
</button>
</div>
{/* Save */}
<button onClick={handleSave} style={{
width: '100%', borderRadius: 99, padding: '9px 14px', background: 'var(--accent)', color: 'var(--accent-text)',
fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', marginTop: 8,
}}>
{t('collab.notes.save')}
</button>
</div>
</div>
</div>,
document.body
)
}
// ── Note Card ───────────────────────────────────────────────────────────────
interface NoteCardProps {
note: CollabNote
currentUser: User
canEdit: boolean
onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void>
onDelete: (noteId: number) => Promise<void>
onEdit: (note: CollabNote) => void
onView: (note: CollabNote) => void
onPreviewFile: (file: NoteFile) => void
getCategoryColor: (category: string) => string
tripId: number
t: (key: string) => string
}
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('[data-tip]'); if (tip) tip.style.opacity = '1' }}
onMouseLeave={e => { const tip = e.currentTarget.querySelector('[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) && (
<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>
)
}
// ── 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 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)
// 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
const created = await collabApi.createNote(tripId, data)
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) }
}
// 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])
const handleUpdateNote = useCallback(async (noteId, data) => {
const result = await collabApi.updateNote(tripId, noteId, data)
const updated = result?.note || result
if (updated) {
setNotes(prev =>
prev.map(n => (n.id === noteId ? { ...n, ...updated } : n))
)
}
}, [tripId])
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 {}
}
const fresh = await collabApi.getNotes(tripId)
if (fresh?.notes) setNotes(fresh.notes)
window.dispatchEvent(new Event('collab-files-changed'))
}
}, [editingNote, tripId, handleUpdateNote])
const handleDeleteNoteFile = useCallback(async (noteId, fileId) => {
try { await collabApi.deleteNoteFile(tripId, noteId, fileId) } catch {}
window.dispatchEvent(new Event('collab-files-changed'))
}, [tripId])
const handleDeleteNote = useCallback(async (noteId) => {
await collabApi.deleteNote(tripId, noteId)
setNotes(prev => prev.filter(n => n.id !== noteId))
window.dispatchEvent(new Event('collab-files-changed'))
}, [tripId])
// ── 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,
}
}
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, handleDeleteNote,
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={handleDeleteNote}
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,
} = 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}
/>
)}
</div>
)
}