Rebrand: - NOMAD → TREK branding across all UI, translations, server, PWA manifest - New TREK logos (dark/light, with/without icon) - Liquid glass toast notifications Bugs Fixed: - HTTPS redirect now opt-in only (FORCE_HTTPS=true), fixes #33 #43 #52 #54 #55 - PDF export "Tag" fallback uses i18n, fixes #15 - Vacay sharing color collision detection, fixes #25 - Backup settings import fix (PR #47) - Atlas country detection uses smallest bounding box, fixes #31 - JPY and zero-decimal currencies formatted correctly, fixes #32 - HTML lang="en" instead of hardcoded "de", fixes #34 - Duplicate translation keys removed - setSelectedAssignmentId crash fixed New Features: - OSM enrichment: Overpass API for opening hours, Wikimedia Commons for photos - Reverse geocoding on map right-click to add places - OIDC config via environment variables (OIDC_ISSUER, OIDC_CLIENT_ID, etc.), fixes #48 - Multi-arch Docker build (ARM64 + AMD64), fixes #11 - File management: star, trash/restore, upload owner, assign to places/bookings, notes - Markdown rendering in Collab Notes with expand modal, fixes #17 - Type-specific booking fields (flight: airline/number/airports, hotel: check-in/out/days, train: number/platform/seat), fixes #35 - Hotel bookings auto-create accommodations, bidirectional sync - Multiple hotels per day with check-in/check-out color coding - Ko-fi and Buy Me a Coffee support cards - GitHub releases proxy with server-side caching
1377 lines
57 KiB
TypeScript
1377 lines
57 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 { 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 (
|
|
<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) {
|
|
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(
|
|
<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()}>
|
|
<img src={url} alt={file.original_name} style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} />
|
|
<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 }}>
|
|
<a href={url} target="_blank" rel="noreferrer" style={{ color: 'rgba(255,255,255,0.7)', display: 'flex' }}><ExternalLink size={15} /></a>
|
|
<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 }}>
|
|
<a href={url} target="_blank" rel="noreferrer" style={{ display: 'flex', alignItems: 'center', gap: 3, fontSize: 11, color: 'var(--text-muted)', textDecoration: 'none' }}><ExternalLink size={13} /></a>
|
|
<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={`${url}#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)' }}>
|
|
<a href={url} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline' }}>Download</a>
|
|
</p>
|
|
</object>
|
|
) : (
|
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
|
|
<a href={url} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14 }}>Download {file.original_name}</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>,
|
|
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 (
|
|
<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 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,
|
|
}}
|
|
onClick={onClose}
|
|
>
|
|
<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 => {
|
|
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 */}
|
|
<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 id="note-file-input" ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { setPendingFiles(prev => [...prev, ...Array.from((e.target as HTMLInputElement).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 && <img 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>
|
|
))}
|
|
<label htmlFor="note-file-input"
|
|
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'}
|
|
</label>
|
|
</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
|
|
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, 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>
|
|
)}
|
|
<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>
|
|
<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>
|
|
<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]}>{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 ? (
|
|
<img 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
|
|
}
|
|
|
|
export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
|
const { t } = useTranslation()
|
|
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 {}
|
|
}
|
|
// 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
|
|
})
|
|
|
|
// ── Loading state ──
|
|
if (loading) {
|
|
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>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div style={{
|
|
height: '100%',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
fontFamily: FONT,
|
|
}}>
|
|
{/* ── Header ── */}
|
|
<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' }}>
|
|
<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>
|
|
<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>
|
|
|
|
{/* ── Category filter pills ── */}
|
|
{categories.length > 0 && (
|
|
<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>
|
|
)}
|
|
|
|
{/* ── Scrollable content ── */}
|
|
<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}
|
|
onUpdate={handleUpdateNote}
|
|
onDelete={handleDeleteNote}
|
|
onEdit={setEditingNote}
|
|
onView={setViewingNote}
|
|
onPreviewFile={setPreviewFile}
|
|
getCategoryColor={getCategoryColor}
|
|
tripId={tripId}
|
|
t={t}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── New Note Modal ── */}
|
|
{/* View note modal */}
|
|
{viewingNote && 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 }}>
|
|
<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]}>{viewingNote.content || ''}</Markdown>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
|
|
{showNewModal && (
|
|
<NoteFormModal
|
|
onClose={() => setShowNewModal(false)}
|
|
onSubmit={handleCreateNote}
|
|
existingCategories={categories}
|
|
categoryColors={categoryColors}
|
|
getCategoryColor={getCategoryColor}
|
|
t={t}
|
|
/>
|
|
)}
|
|
|
|
{/* ── Edit Note Modal ── */}
|
|
{editingNote && (
|
|
<NoteFormModal
|
|
note={editingNote}
|
|
tripId={tripId}
|
|
onClose={() => setEditingNote(null)}
|
|
onSubmit={handleEditSubmit}
|
|
onDeleteFile={handleDeleteNoteFile}
|
|
existingCategories={categories}
|
|
categoryColors={categoryColors}
|
|
getCategoryColor={getCategoryColor}
|
|
t={t}
|
|
/>
|
|
)}
|
|
|
|
{/* ── File Preview ── */}
|
|
<FilePreviewPortal file={previewFile} onClose={() => setPreviewFile(null)} />
|
|
|
|
{/* ── Category Settings Modal ── */}
|
|
{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>
|
|
)
|
|
}
|