mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
152 lines
7.5 KiB
TypeScript
152 lines
7.5 KiB
TypeScript
import { useCallback } from 'react'
|
|
import { avatarSrc } from '../../utils/avatarSrc'
|
|
import { safeExternalHref } from '../../utils/safeUrl'
|
|
import Markdown from 'react-markdown'
|
|
import remarkGfm from 'remark-gfm'
|
|
import remarkBreaks from 'remark-breaks'
|
|
import { sanitizedMarkdownPlugins, sanitizedMarkdownComponents } from '../shared/markdownSanitize'
|
|
import { ExternalLink, Maximize2, MoreHorizontal, Pencil, Pin, PinOff, Trash2 } from 'lucide-react'
|
|
import { Tooltip } from '../shared/Tooltip'
|
|
import { ContextMenu, useContextMenu } from '../shared/ContextMenu'
|
|
import { Eyebrow, fs } from '../Planner/bookings/bookingParts'
|
|
import { AuthedImg } from './CollabNotesAuthedImg'
|
|
import { UserAvatar } from './CollabNotesUserAvatar'
|
|
import { NoteHeadBand } from './CollabNoteHead'
|
|
import type { CollabNote, NoteFile } from './CollabNotes.types'
|
|
import type { User } from '../../types'
|
|
|
|
// ── Note Card ───────────────────────────────────────────────────────────────
|
|
interface NoteCardProps {
|
|
note: CollabNote
|
|
currentUser: User
|
|
canEdit: boolean
|
|
onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void>
|
|
onDelete: (noteId: number) => void
|
|
onEdit: (note: CollabNote) => void
|
|
onView: (note: CollabNote) => void
|
|
onPreviewFile: (file: NoteFile) => void
|
|
getCategoryColor: (category: string) => string
|
|
tripId: number
|
|
t: (key: string) => string
|
|
}
|
|
|
|
/** The link chip shows the host: that is what people recognise, "www." is noise. */
|
|
function linkHost(url: string): string {
|
|
try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
|
|
}
|
|
|
|
const LINK_BTN = 'grid h-[26px] w-[26px] flex-none place-items-center rounded-full bg-surface-card shadow-sm'
|
|
|
|
/**
|
|
* A note as the booking cards are drawn, kept compact: one head band tinted by
|
|
* its category, carrying the category dot, the title, its link, the actions
|
|
* behind "…" and who wrote it; under it the text and the files.
|
|
*/
|
|
export function NoteCard({ note, canEdit, onUpdate, onDelete, onEdit, onView, onPreviewFile, getCategoryColor, t }: NoteCardProps) {
|
|
const author = note.author || note.user || { username: note.username, avatar: note.avatar_url || avatarSrc(note.avatar) }
|
|
const color = getCategoryColor ? getCategoryColor(note.category) : (note.color || '#6366f1')
|
|
const attachments = note.attachments || []
|
|
const shown = attachments.slice(0, 3)
|
|
const hidden = attachments.length - shown.length
|
|
// Allow-listed like everywhere else a note's link opens: the field takes any string.
|
|
const websiteHref = safeExternalHref(note.website)
|
|
const hasBody = !!note.content || attachments.length > 0
|
|
const menu = useContextMenu()
|
|
|
|
const handleTogglePin = useCallback(() => {
|
|
void onUpdate(note.id, { pinned: !note.pinned })
|
|
}, [note.id, note.pinned, onUpdate])
|
|
|
|
const handleDelete = useCallback(() => {
|
|
onDelete(note.id)
|
|
}, [note.id, onDelete])
|
|
|
|
const menuItems = [
|
|
...(note.content ? [{ label: t('common.expand'), icon: Maximize2, onClick: () => onView?.(note) }] : []),
|
|
...(canEdit ? [
|
|
{ label: note.pinned ? t('collab.notes.unpin') : t('collab.notes.pin'), icon: note.pinned ? PinOff : Pin, onClick: handleTogglePin },
|
|
{ label: t('collab.notes.edit'), icon: Pencil, onClick: () => onEdit?.(note) },
|
|
{ divider: true },
|
|
{ label: t('collab.notes.delete'), icon: Trash2, onClick: handleDelete, danger: true },
|
|
] : []),
|
|
]
|
|
|
|
return (
|
|
<article
|
|
aria-label={note.title}
|
|
className="group flex flex-col overflow-hidden rounded-2xl border bg-surface-card transition-shadow hover:shadow-md"
|
|
// A pinned note keeps its category colour on the frame, the way a selected booking card does.
|
|
style={{ borderColor: note.pinned ? `color-mix(in srgb, ${color} 45%, transparent)` : 'var(--border-faint)' }}
|
|
>
|
|
<NoteHeadBand title={note.title} color={color} category={note.category || null} pinned={!!note.pinned} bordered={hasBody} trailing={<>
|
|
{/* The link rides in the title row as one round button; the host is in its tooltip. */}
|
|
{note.website && (
|
|
<Tooltip label={websiteHref ? linkHost(websiteHref) : note.website}>
|
|
{websiteHref ? (
|
|
<a href={websiteHref} target="_blank" rel="noopener noreferrer" aria-label={linkHost(websiteHref)} className={`${LINK_BTN} text-content-muted hover:text-content`}>
|
|
<ExternalLink size={12} strokeWidth={2.2} />
|
|
</a>
|
|
) : (
|
|
<span role="img" aria-label={note.website} className={`${LINK_BTN} text-content-faint`}>
|
|
<ExternalLink size={12} strokeWidth={2.2} />
|
|
</span>
|
|
)}
|
|
</Tooltip>
|
|
)}
|
|
{menuItems.length > 0 && (
|
|
<Tooltip label={t('files.menu')} disabled={!!menu.menu}>
|
|
<button type="button" onClick={e => menu.open(e, menuItems, true)} aria-label={t('files.menu')} aria-haspopup="menu"
|
|
className="grid h-[26px] w-[26px] flex-none place-items-center rounded-full text-content-muted transition-colors hover:bg-surface-card hover:text-content">
|
|
<MoreHorizontal size={15} strokeWidth={2} />
|
|
</button>
|
|
</Tooltip>
|
|
)}
|
|
<Tooltip label={author.username}>
|
|
<span role="img" aria-label={author.username} className="flex flex-none"><UserAvatar user={author} size={20} /></span>
|
|
</Tooltip>
|
|
</>} />
|
|
|
|
{hasBody && (
|
|
<div className="flex flex-1 flex-col gap-2 px-3 pb-2.5 pt-2">
|
|
{note.content && (
|
|
<div className="collab-note-md line-clamp-3 break-words text-content-muted" style={{ ...fs(12, 'body'), lineHeight: 1.45 }}>
|
|
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={sanitizedMarkdownPlugins} components={sanitizedMarkdownComponents}>{note.content}</Markdown>
|
|
</div>
|
|
)}
|
|
|
|
{attachments.length > 0 && (
|
|
<div>
|
|
<Eyebrow className="mb-[3px]">{t('files.title')}</Eyebrow>
|
|
<div className="flex items-center gap-1.5">
|
|
{shown.map(a => {
|
|
const isImage = a.mime_type?.startsWith('image/')
|
|
const isPdf = a.mime_type === 'application/pdf'
|
|
const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
|
return isImage ? (
|
|
<AuthedImg key={a.id} src={a.url} alt={a.original_name}
|
|
style={{ width: 40, height: 40, objectFit: 'cover', borderRadius: 10, cursor: 'pointer' }}
|
|
onClick={() => onPreviewFile?.(a)} />
|
|
) : (
|
|
<Tooltip key={a.id} label={a.original_name || ext}>
|
|
<button type="button" aria-label={a.original_name} onClick={() => onPreviewFile?.(a)}
|
|
className={`grid h-10 w-10 place-items-center rounded-[10px] font-geist font-bold tracking-[.03em] transition-transform hover:scale-[1.06] ${isPdf ? 'bg-danger-soft text-danger' : 'bg-surface-secondary text-content-muted'}`}
|
|
style={fs(9)}>
|
|
{ext}
|
|
</button>
|
|
</Tooltip>
|
|
)
|
|
})}
|
|
{hidden > 0 && (
|
|
<span className="rounded-full bg-surface-tertiary px-2 py-[2px] font-geist font-bold text-content-muted" style={fs(10)}>+{hidden}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<ContextMenu menu={menu.menu} onClose={menu.close} />
|
|
</article>
|
|
)
|
|
}
|