mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
0dd07befa7
The Plan tab was the last trip tab still in the old design. It now reads like Bookings, Costs, Lists, Files and Collab, without losing a function: - Day plan: each day is a card with a head band (day tile with weather, stay and rental pills, a "+" menu for place, stay, transport, transit and note), rows with "..." menus, white badges and a white route bar. - Places column: add, search and filters in three rows, a show dropdown, category and rating filters, and a selection bar at the foot. - Place inspector, day details and the stay editor on the shared dialog frame; the place form in the editor look with the name in the head band. - Clicking a booking or transport anywhere on the plan opens the booking detail popup first; Edit leads to the editor. The popup is one host shared with the Bookings and Transports tabs. - Files and Collab dialogs, notes, polls, links and chat moved to the same frame; shared DialogShell and dialogParts hold the building blocks. - The map hover card shows the place photo and follows the theme. Every new and reworded string is translated into all locales, and a few older English leftovers (help center, cost split, import hints) with them. Phone screens keep their own look.
350 lines
17 KiB
TypeScript
350 lines
17 KiB
TypeScript
import { cloneElement, CSSProperties, FormEvent, ReactElement, useEffect, useId, useRef, useState } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { ExternalLink, Link2, Pencil, Pin, Plus, Trash2, X } from 'lucide-react'
|
|
import CollabPanelHead, { HEAD_ACTION } from './CollabPanelHead'
|
|
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../shared/DialogShell'
|
|
import { EditorField, INPUT } from '../shared/dialogParts'
|
|
import { useIsPhone } from '../../mobile/useIsPhone'
|
|
import { collabApi } from '../../api/client'
|
|
import { addListener, removeListener } from '../../api/websocket'
|
|
import { useTranslation } from '../../i18n'
|
|
import { useCanDo } from '../../store/permissionsStore'
|
|
import { useTripStore } from '../../store/tripStore'
|
|
import ConfirmDialog from '../shared/ConfirmDialog'
|
|
import EmptyState from '../shared/EmptyState'
|
|
import { Tooltip } from '../shared/Tooltip'
|
|
import { useToast } from '../shared/Toast'
|
|
|
|
const FONT = "var(--font-system)"
|
|
|
|
interface CollabLink {
|
|
id: number
|
|
title: string
|
|
url: string
|
|
pinned: boolean | number
|
|
}
|
|
|
|
function LinkIcon({ url }: { url: string }) {
|
|
const [failed, setFailed] = useState(false)
|
|
let favicon = ''
|
|
try { favicon = new URL('/favicon.ico', url).href } catch { /* not a parseable url, fall through to the glyph */ }
|
|
return (
|
|
<span className="collab-link-chip__icon" aria-hidden="true">
|
|
{!favicon || failed ? <Link2 size={13} /> : <img src={favicon} alt="" onError={() => setFailed(true)} />}
|
|
<span className="collab-link-chip__go"><ExternalLink size={13} /></span>
|
|
</span>
|
|
)
|
|
}
|
|
|
|
/** The part of the address worth a chip's width: the host, without a leading www. */
|
|
/** The desktop names a chip's action in the shared tooltip; the phone keeps its plain title. */
|
|
function ChipTip({ label, phone, children }: { label: string; phone: boolean; children: ReactElement<{ title?: string }> }) {
|
|
return phone ? cloneElement(children, { title: label }) : <Tooltip label={label}>{children}</Tooltip>
|
|
}
|
|
|
|
function hostOf(url: string): string {
|
|
try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
|
|
}
|
|
|
|
/**
|
|
* The add and edit form as a modal, like the poll and note panels next to it.
|
|
* Inline it pushed the list down and gave the panel a second header, which is
|
|
* what made this tab read as a different app from its four siblings. With a
|
|
* link handed in it edits that link (#2414): a link used to be delete-and-add
|
|
* once its title or address needed a correction.
|
|
*/
|
|
function LinkModal({ link, onClose, onSave }: { link?: CollabLink; onClose: () => void; onSave: (link: { title: string; url: string }) => Promise<void> }) {
|
|
const { t } = useTranslation()
|
|
const [title, setTitle] = useState(link?.title ?? '')
|
|
const [url, setUrl] = useState(link?.url ?? '')
|
|
const [busy, setBusy] = useState(false)
|
|
const isPhone = useIsPhone()
|
|
const labelId = useId()
|
|
const valid = title.trim().length > 0 && url.trim().length > 0
|
|
|
|
const submit = async (event?: FormEvent) => {
|
|
event?.preventDefault()
|
|
if (!valid || busy) return
|
|
setBusy(true)
|
|
try {
|
|
await onSave({ title: title.trim(), url: url.trim() })
|
|
onClose()
|
|
} catch {
|
|
// The caller already said what went wrong; the form stays open with the input in it.
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
const field: CSSProperties = {
|
|
width: '100%', boxSizing: 'border-box', padding: '8px 12px', borderRadius: 10, fontFamily: 'inherit',
|
|
fontSize: 'calc(13px * var(--fs-scale-body, 1))', outline: 'none',
|
|
border: '1px solid var(--border-primary)', background: 'var(--bg-input)', color: 'var(--text-primary)',
|
|
}
|
|
const label: CSSProperties = {
|
|
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
|
color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4,
|
|
}
|
|
|
|
// The desktop names the link in the head band like every planner dialog.
|
|
if (!isPhone) return (
|
|
<DialogShell
|
|
onClose={onClose}
|
|
labelledBy={labelId}
|
|
width="narrow"
|
|
onSubmit={submit}
|
|
header={(
|
|
<DialogHeader
|
|
tile={<DialogTile><Link2 size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
|
tint={NEUTRAL_TINT}
|
|
labelId={labelId}
|
|
onClose={onClose}
|
|
eyebrow={t(link ? 'collab.links.edit' : 'collab.links.add')}
|
|
titleInput={{
|
|
value: title,
|
|
onChange: setTitle,
|
|
label: t('collab.links.titlePlaceholder'),
|
|
placeholder: t('collab.links.titlePlaceholder'),
|
|
autoFocus: true,
|
|
onKeyDown: e => { if (e.key === 'Enter') void submit() },
|
|
}}
|
|
/>
|
|
)}
|
|
footer={(
|
|
<DialogFooter>
|
|
<FooterSpacer />
|
|
<DialogButton onClick={onClose}>{t('collab.links.cancel')}</DialogButton>
|
|
<DialogButton variant="primary" onClick={() => void submit()} disabled={!valid || busy}>{busy ? '...' : t('collab.links.save')}</DialogButton>
|
|
</DialogFooter>
|
|
)}
|
|
>
|
|
<EditorField label={t('reservations.urlLabel')} htmlFor="collab-link-url">
|
|
<input id="collab-link-url" type="url" required value={url} onChange={e => setUrl(e.target.value)} placeholder={t('collab.links.urlPlaceholder')} className={INPUT} />
|
|
</EditorField>
|
|
</DialogShell>
|
|
)
|
|
|
|
return createPortal(
|
|
<div role="presentation" 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={e => { if (e.target === e.currentTarget) onClose() }}>
|
|
<form style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 400, maxHeight: '90vh', overflow: 'auto', border: '1px solid var(--border-faint)' }} onSubmit={submit}>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
|
|
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t(link ? 'collab.links.edit' : 'collab.links.add')}</h3>
|
|
<button type="button" onClick={onClose} aria-label={t('collab.links.cancel')} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}><X size={16} /></button>
|
|
</div>
|
|
<div style={{ padding: '14px 16px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
<div>
|
|
<label htmlFor="collab-link-title" style={{ ...label, display: 'block' }}>{t('collab.links.titlePlaceholder')}</label>
|
|
<input id="collab-link-title" value={title} onChange={e => setTitle(e.target.value)} placeholder={t('collab.links.titlePlaceholder')} autoFocus style={field} />
|
|
</div>
|
|
<div>
|
|
<label htmlFor="collab-link-url" style={{ ...label, display: 'block' }}>{t('collab.links.urlPlaceholder')}</label>
|
|
<input id="collab-link-url" type="url" required value={url} onChange={e => setUrl(e.target.value)} placeholder={t('collab.links.urlPlaceholder')} style={field} />
|
|
</div>
|
|
<button type="submit" disabled={!valid || busy} style={{
|
|
width: '100%', borderRadius: 99, padding: '9px 14px', background: valid && !busy ? 'var(--accent)' : 'var(--border-primary)',
|
|
color: valid && !busy ? 'var(--accent-text)' : 'var(--text-faint)', fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
|
fontWeight: 600, border: 'none', cursor: valid && !busy ? 'pointer' : 'default', fontFamily: FONT,
|
|
}}>
|
|
{busy ? '...' : t('collab.links.save')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>,
|
|
document.body,
|
|
)
|
|
}
|
|
|
|
export default function CollabLinks({ tripId }: { tripId: number }) {
|
|
const { t } = useTranslation()
|
|
const isPhone = useIsPhone()
|
|
const trip = useTripStore(s => s.trip)
|
|
const canEdit = useCanDo()('collab_edit', trip)
|
|
const toast = useToast()
|
|
const [links, setLinks] = useState<CollabLink[]>([])
|
|
const [showForm, setShowForm] = useState(false)
|
|
const [editing, setEditing] = useState<CollabLink | null>(null)
|
|
const [pendingDeleteId, setPendingDeleteId] = useState<number | null>(null)
|
|
|
|
// Both are new objects on every render, so depending on them would reload the
|
|
// list after each keystroke and drop a link that was just added back out of it.
|
|
const toastRef = useRef(toast)
|
|
toastRef.current = toast
|
|
const tRef = useRef(t)
|
|
tRef.current = t
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
collabApi.getLinks(tripId)
|
|
.then(d => { if (!cancelled) setLinks(d.links || []) })
|
|
.catch(err => {
|
|
if (cancelled) return
|
|
console.error('Failed to load collab links:', err)
|
|
toastRef.current.error(tRef.current('common.error'))
|
|
})
|
|
return () => { cancelled = true }
|
|
}, [tripId])
|
|
|
|
// Live sync, the same shape the notes panel uses. Without it the three
|
|
// collab:link events the server broadcasts would arrive nowhere and a link
|
|
// somebody else added would only appear on the next reload.
|
|
useEffect(() => {
|
|
if (!tripId) return
|
|
const handler = (msg: { type?: string; tripId?: number | string; link?: CollabLink; linkId?: number }) => {
|
|
// The panel is not remounted on a trip change, so an event still in
|
|
// flight from the trip we just left must not land in this list.
|
|
if (String(msg?.tripId) !== String(tripId)) return
|
|
if (msg.type === 'collab:link:created' && msg.link) {
|
|
setLinks(prev => prev.some(l => l.id === msg.link!.id) ? prev : [msg.link!, ...prev])
|
|
}
|
|
if (msg.type === 'collab:link:updated' && msg.link) {
|
|
setLinks(prev => prev
|
|
.map(l => l.id === msg.link!.id ? { ...l, ...msg.link! } : l)
|
|
.sort((a, b) => Number(b.pinned) - Number(a.pinned)))
|
|
}
|
|
if (msg.type === 'collab:link:deleted' && msg.linkId) {
|
|
setLinks(prev => prev.filter(l => l.id !== msg.linkId))
|
|
}
|
|
}
|
|
addListener(handler)
|
|
return () => removeListener(handler)
|
|
}, [tripId])
|
|
|
|
// Every write says something when it fails. Silence left the pin looking
|
|
// unchanged and the delete looking ignored, with an unhandled rejection in
|
|
// the console as the only trace.
|
|
const add = async (data: { title: string; url: string }) => {
|
|
try {
|
|
const d = await collabApi.createLink(tripId, data)
|
|
setLinks(v => [d.link, ...v])
|
|
} catch (err) {
|
|
console.error('Failed to add collab link:', err)
|
|
toast.error(t('common.error'))
|
|
throw err
|
|
}
|
|
}
|
|
|
|
const toggle = async (link: CollabLink) => {
|
|
try {
|
|
const d = await collabApi.updateLink(tripId, link.id, { pinned: !link.pinned })
|
|
setLinks(v => v.map(x => x.id === link.id ? d.link : x).sort((a, b) => Number(b.pinned) - Number(a.pinned)))
|
|
} catch (err) {
|
|
console.error('Failed to pin collab link:', err)
|
|
toast.error(t('common.error'))
|
|
}
|
|
}
|
|
|
|
const edit = async (link: CollabLink, data: { title: string; url: string }) => {
|
|
try {
|
|
const d = await collabApi.updateLink(tripId, link.id, data)
|
|
setLinks(v => v.map(x => x.id === link.id ? d.link : x))
|
|
} catch (err) {
|
|
console.error('Failed to edit collab link:', err)
|
|
toast.error(t('common.error'))
|
|
throw err
|
|
}
|
|
}
|
|
|
|
const remove = async (id: number) => {
|
|
try {
|
|
await collabApi.deleteLink(tripId, id)
|
|
setLinks(v => v.filter(x => x.id !== id))
|
|
} catch (err) {
|
|
console.error('Failed to delete collab link:', err)
|
|
toast.error(t('common.error'))
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}>
|
|
{/* Header: the desktop's head band; the phone keeps its own */}
|
|
{!isPhone ? (
|
|
<CollabPanelHead
|
|
icon={Link2}
|
|
title={t('collab.tabs.links')}
|
|
count={links.length}
|
|
actions={canEdit && (
|
|
<button type="button" onClick={() => setShowForm(true)} className={HEAD_ACTION}>
|
|
<Plus size={12} /> {t('collab.links.add')}
|
|
</button>
|
|
)}
|
|
/>
|
|
) : (
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}>
|
|
<h3 style={{ margin: 0, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 7, letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
|
<Link2 size={14} color="var(--text-faint)" />
|
|
{t('collab.tabs.links')}
|
|
</h3>
|
|
{canEdit && (
|
|
<button type="button" onClick={() => setShowForm(true)} style={{
|
|
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
|
|
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
|
fontFamily: FONT, border: 'none', cursor: 'pointer',
|
|
}}>
|
|
<Plus size={12} /> {t('collab.links.add')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Content: one chip per link, two to a row (see .collab-link-chip). The chip is
|
|
the link, with the host beside the title; the actions sit in its tail. */}
|
|
<div className={isPhone ? 'chat-scroll collab-link-grid' : 'chat-scroll collab-link-grid collab-link-grid--desk'} style={{ flex: 1, overflowY: 'auto', padding: isPhone ? '2px 12px 12px' : 12 }}>
|
|
{links.length === 0 ? (
|
|
<EmptyState scene="links" title={t('collab.links.empty')} />
|
|
) : (
|
|
<div className="collab-link-grid__cells">
|
|
{links.map(link => (
|
|
<div key={link.id} className={link.pinned ? 'collab-link-chip collab-link-chip--pinned' : 'collab-link-chip'}>
|
|
<ChipTip label={t('collab.links.open')} phone={isPhone}>
|
|
<a className="collab-link-chip__main" href={link.url} target="_blank" rel="noreferrer">
|
|
{/* Keyed on the address: the icon remembers a favicon that failed, and
|
|
an edit keeps the chip's id, so a corrected address got the glyph. */}
|
|
<LinkIcon key={link.url} url={link.url} />
|
|
<span className="collab-link-chip__text">
|
|
<span className="collab-link-chip__title">{link.title}</span>
|
|
<span className="collab-link-chip__host">{hostOf(link.url)}</span>
|
|
</span>
|
|
</a>
|
|
</ChipTip>
|
|
{canEdit && (
|
|
<span className="collab-link-chip__actions">
|
|
<ChipTip label={t('collab.links.edit')} phone={isPhone}>
|
|
<button type="button" className="collab-link-chip__action" onClick={() => setEditing(link)} aria-label={t('collab.links.edit')}>
|
|
<Pencil size={13} aria-hidden="true" />
|
|
</button>
|
|
</ChipTip>
|
|
<ChipTip label={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')} phone={isPhone}>
|
|
<button type="button" className={link.pinned ? 'collab-link-chip__action collab-link-chip__action--on' : 'collab-link-chip__action'} onClick={() => toggle(link)} aria-label={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')}>
|
|
<Pin size={13} fill={link.pinned ? 'currentColor' : 'none'} aria-hidden="true" />
|
|
</button>
|
|
</ChipTip>
|
|
<ChipTip label={t('collab.links.delete')} phone={isPhone}>
|
|
<button type="button" className="collab-link-chip__action" onClick={() => setPendingDeleteId(link.id)} aria-label={t('collab.links.delete')}>
|
|
<Trash2 size={13} aria-hidden="true" />
|
|
</button>
|
|
</ChipTip>
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Create and edit modals */}
|
|
{showForm && <LinkModal onClose={() => setShowForm(false)} onSave={add} />}
|
|
{editing && <LinkModal key={editing.id} link={editing} onClose={() => setEditing(null)} onSave={data => edit(editing, data)} />}
|
|
|
|
{/* The delete is shared: the link goes for every member, and on the phone the
|
|
button is a thumb's width from pin. Same guard as the notes panel. */}
|
|
<ConfirmDialog
|
|
isOpen={pendingDeleteId !== null}
|
|
onClose={() => setPendingDeleteId(null)}
|
|
onConfirm={() => (pendingDeleteId !== null ? remove(pendingDeleteId) : undefined)}
|
|
title={t('collab.links.confirmDeleteTitle')}
|
|
message={t('collab.links.confirmDeleteBody')}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|