mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(planner): markdown toolbar for journey notes + calmer transit search form
The journey notes gain a proper markdown toolbar (bold, italic, strike, heading, list, checklist, link, code) that wraps the selection or prefixes the current lines. The transit search options settle into one calm card: depart/arrive + time + date and the ranking preference share the top row, the mode filters and the search button share the bottom row, with the mode chips restyled from heavy filled pills to quiet toggles. The day-plan row drops the transfer count — the leg chips already tell the story.
This commit is contained in:
@@ -345,7 +345,8 @@ describe('DayPlanSidebar', () => {
|
||||
// Line chip + transfer summary render inline in the timeline row; the
|
||||
// title uses an arrow icon, so its parts are separate text nodes.
|
||||
expect(screen.getByText('U2')).toBeInTheDocument()
|
||||
expect(screen.getByText(/1 transfers/)).toBeInTheDocument()
|
||||
// Transfer counts stay out of the compact row — the chips say it all.
|
||||
expect(screen.queryByText(/1 transfers/)).not.toBeInTheDocument()
|
||||
// Clicking the row opens the journey view — not the edit form.
|
||||
await user.click(screen.getByText('Fernsehturm'))
|
||||
expect(onEditTransport).not.toHaveBeenCalled()
|
||||
|
||||
@@ -2089,7 +2089,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
</div>
|
||||
{transitMeta ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginTop: 3 }}>
|
||||
<TransitLegChips legs={transitMeta.legs} transfers={transitMeta.transfers} size="sm" t={t} />
|
||||
<TransitLegChips legs={transitMeta.legs} size="sm" t={t} />
|
||||
</div>
|
||||
) : subtitle && (
|
||||
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
|
||||
@@ -84,6 +84,18 @@ describe('TransitJourneyModal', () => {
|
||||
expect(bold?.textContent).toBe('bold')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSITJOURNEY-007: the markdown toolbar wraps the note text', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TransitJourneyModal {...makeProps()} />)
|
||||
const area = screen.getByPlaceholderText(/notes/i) as HTMLTextAreaElement
|
||||
await user.type(area, 'coffee')
|
||||
area.setSelectionRange(0, 6)
|
||||
await user.click(screen.getByRole('button', { name: 'Bold' }))
|
||||
expect(area.value).toBe('**coffee**')
|
||||
await user.click(screen.getByRole('button', { name: 'Checklist' }))
|
||||
expect((screen.getByPlaceholderText(/notes/i) as HTMLTextAreaElement).value).toMatch(/^- \[ \] /)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSITJOURNEY-003: change route triggers onChangeRoute', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChangeRoute = vi.fn()
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { ArrowRight, ArrowRightLeft, Clock, Footprints, Pencil, RefreshCw, TramFront, Trash2 } from 'lucide-react'
|
||||
import { ArrowRight, ArrowRightLeft, Bold, Clock, Code, Footprints, Heading2, Italic, Link2, List, ListChecks, Pencil, RefreshCw, Strikethrough, TramFront, Trash2 } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -55,6 +55,51 @@ export default function TransitJourneyModal({ reservation, onClose, onSave, onDe
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
const titleInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const notesRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
|
||||
// Markdown toolbar: wrap the selection / prefix the current lines, then
|
||||
// restore focus and a sensible cursor.
|
||||
const applyMd = (action: { wrap?: [string, string]; linePrefix?: string }) => {
|
||||
const el = notesRef.current
|
||||
if (!el) return
|
||||
const start = el.selectionStart ?? 0
|
||||
const end = el.selectionEnd ?? 0
|
||||
const value = notes
|
||||
let next = value
|
||||
let selStart = start
|
||||
let selEnd = end
|
||||
if (action.wrap) {
|
||||
const [pre, post] = action.wrap
|
||||
const selected = value.slice(start, end)
|
||||
next = value.slice(0, start) + pre + selected + post + value.slice(end)
|
||||
selStart = start + pre.length
|
||||
selEnd = selStart + selected.length
|
||||
} else if (action.linePrefix) {
|
||||
const prefix = action.linePrefix
|
||||
const lineStart = value.lastIndexOf('\n', start - 1) + 1
|
||||
const block = value.slice(lineStart, end)
|
||||
const prefixed = block.split('\n').map(l => prefix + l).join('\n')
|
||||
next = value.slice(0, lineStart) + prefixed + value.slice(end)
|
||||
selStart = start + prefix.length
|
||||
selEnd = end + (prefixed.length - block.length)
|
||||
}
|
||||
setNotes(next)
|
||||
requestAnimationFrame(() => {
|
||||
el.focus()
|
||||
el.setSelectionRange(selStart, selEnd)
|
||||
})
|
||||
}
|
||||
|
||||
const MD_TOOLS: { Icon: typeof Bold; label: string; action: { wrap?: [string, string]; linePrefix?: string } }[] = [
|
||||
{ Icon: Bold, label: 'Bold', action: { wrap: ['**', '**'] } },
|
||||
{ Icon: Italic, label: 'Italic', action: { wrap: ['*', '*'] } },
|
||||
{ Icon: Strikethrough, label: 'Strikethrough', action: { wrap: ['~~', '~~'] } },
|
||||
{ Icon: Heading2, label: 'Heading', action: { linePrefix: '## ' } },
|
||||
{ Icon: List, label: 'List', action: { linePrefix: '- ' } },
|
||||
{ Icon: ListChecks, label: 'Checklist', action: { linePrefix: '- [ ] ' } },
|
||||
{ Icon: Link2, label: 'Link', action: { wrap: ['[', '](https://)'] } },
|
||||
{ Icon: Code, label: 'Code', action: { wrap: ['`', '`'] } },
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
setTitle(res.title || '')
|
||||
@@ -241,22 +286,38 @@ export default function TransitJourneyModal({ reservation, onClose, onSave, onDe
|
||||
|
||||
{/* notes — full width, markdown */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6, gap: 8, flexWrap: 'wrap' }}>
|
||||
<label className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.notes')}</label>
|
||||
{canEdit && (
|
||||
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 8, padding: 2, gap: 2 }}>
|
||||
{([['write', t('common.edit')], ['preview', t('common.preview')]] as const).map(([tab, label]) => (
|
||||
<button key={tab} type="button" onClick={() => setNotesTab(tab)}
|
||||
className={notesTab === tab ? 'bg-surface-card text-content' : 'text-content-muted'}
|
||||
style={{ padding: '4px 12px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, borderRadius: 6, border: 0, cursor: 'pointer', fontFamily: 'inherit', background: notesTab === tab ? undefined : 'transparent' }}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{notesTab === 'write' && (
|
||||
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 8, padding: 2, gap: 1 }}>
|
||||
{MD_TOOLS.map(({ Icon, label, action }) => (
|
||||
<button key={label} type="button" onClick={() => applyMd(action)} title={label} aria-label={label}
|
||||
className="text-content-muted"
|
||||
style={{ width: 26, height: 24, display: 'grid', placeItems: 'center', borderRadius: 6, border: 0, background: 'transparent', cursor: 'pointer' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-card)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}>
|
||||
<Icon size={13} strokeWidth={2} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 8, padding: 2, gap: 2 }}>
|
||||
{([['write', t('common.edit')], ['preview', t('common.preview')]] as const).map(([tab, label]) => (
|
||||
<button key={tab} type="button" onClick={() => setNotesTab(tab)}
|
||||
className={notesTab === tab ? 'bg-surface-card text-content' : 'text-content-muted'}
|
||||
style={{ padding: '4px 12px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, borderRadius: 6, border: 0, cursor: 'pointer', fontFamily: 'inherit', background: notesTab === tab ? undefined : 'transparent' }}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && notesTab === 'write' ? (
|
||||
<textarea
|
||||
ref={notesRef}
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
placeholder={t('reservations.notesPlaceholder')}
|
||||
|
||||
@@ -479,60 +479,65 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
<StopPicker label={t('transit.to')} value={to} onPick={setTo} quickPicks={quickPicks} near={near} placeholder={t('transit.searchStop')} />
|
||||
</div>
|
||||
|
||||
{/* time + depart/arrive */}
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3 }}>
|
||||
<button onClick={() => setArriveBy(false)} style={segBtn(!arriveBy)}>{t('transit.depart')}</button>
|
||||
<button onClick={() => setArriveBy(true)} style={segBtn(arriveBy)}>{t('transit.arrive')}</button>
|
||||
{/* search options — one calm card: when + how on top, modes + go below */}
|
||||
<div className="bg-surface-tertiary" style={{ borderRadius: 14, padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3 }}>
|
||||
<button onClick={() => setArriveBy(false)} style={segBtn(!arriveBy)}>{t('transit.depart')}</button>
|
||||
<button onClick={() => setArriveBy(true)} style={segBtn(arriveBy)}>{t('transit.arrive')}</button>
|
||||
</div>
|
||||
<div style={{ width: 110 }}>
|
||||
<CustomTimePicker value={time} onChange={setTime} />
|
||||
</div>
|
||||
{day.date && (
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||
<Clock size={12} />
|
||||
{new Date(day.date + 'T00:00:00Z').toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3 }}>
|
||||
<button onClick={() => setPref('best')} style={segBtn(pref === 'best')}>{t('transit.pref.best')}</button>
|
||||
<button onClick={() => setPref('transfers')} style={segBtn(pref === 'transfers')}>{t('transit.pref.transfers')}</button>
|
||||
<button onClick={() => setPref('walking')} style={segBtn(pref === 'walking')}>{t('transit.pref.walking')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ width: 110 }}>
|
||||
<CustomTimePicker value={time} onChange={setTime} />
|
||||
</div>
|
||||
{day.date && (
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||
<Clock size={12} />
|
||||
{new Date(day.date + 'T00:00:00Z').toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* mode chips */}
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{MODE_GROUPS.map(m => {
|
||||
const active = activeModes.has(m.key)
|
||||
return (
|
||||
<button key={m.key} onClick={() => toggleMode(m.key)}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||
border: '1.5px solid', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
cursor: 'pointer', fontFamily: 'inherit', transition: 'all 0.12s',
|
||||
background: active ? 'var(--text-primary)' : 'transparent',
|
||||
borderColor: active ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
color: active ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||
}}>
|
||||
<m.Icon size={12} strokeWidth={2} />
|
||||
{t(m.labelKey)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div style={{ height: 1, background: 'var(--border-faint)' }} />
|
||||
|
||||
{/* preference + search */}
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3 }}>
|
||||
<button onClick={() => setPref('best')} style={segBtn(pref === 'best')}>{t('transit.pref.best')}</button>
|
||||
<button onClick={() => setPref('transfers')} style={segBtn(pref === 'transfers')}>{t('transit.pref.transfers')}</button>
|
||||
<button onClick={() => setPref('walking')} style={segBtn(pref === 'walking')}>{t('transit.pref.walking')}</button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', gap: 5, flexWrap: 'wrap' }}>
|
||||
{MODE_GROUPS.map(m => {
|
||||
const active = activeModes.has(m.key)
|
||||
return (
|
||||
<button key={m.key} onClick={() => toggleMode(m.key)}
|
||||
className={active ? 'bg-surface-card text-content' : 'text-content-faint'}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||
border: '1px solid', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit', transition: 'all 0.12s',
|
||||
background: active ? undefined : 'transparent',
|
||||
borderColor: active ? 'var(--border-primary)' : 'transparent',
|
||||
boxShadow: active ? '0 1px 3px rgba(0,0,0,0.06)' : 'none',
|
||||
opacity: active ? 1 : 0.75,
|
||||
}}>
|
||||
<m.Icon size={12} strokeWidth={2} />
|
||||
{t(m.labelKey)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={search}
|
||||
disabled={!from || !to || loading}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, border: 'none', borderRadius: 10, padding: '9px 18px', fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: (!from || !to || loading) ? 'default' : 'pointer', fontFamily: 'inherit', opacity: (!from || !to || loading) ? 0.55 : 1, flexShrink: 0 }}
|
||||
>
|
||||
<Search size={14} strokeWidth={2.2} />
|
||||
{loading ? t('transit.searching') : t('transit.search')}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={search}
|
||||
disabled={!from || !to || loading}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{ marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: 6, border: 'none', borderRadius: 10, padding: '9px 18px', fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: (!from || !to || loading) ? 'default' : 'pointer', fontFamily: 'inherit', opacity: (!from || !to || loading) ? 0.55 : 1 }}
|
||||
>
|
||||
<Search size={14} strokeWidth={2.2} />
|
||||
{loading ? t('transit.searching') : t('transit.search')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* results */}
|
||||
|
||||
Reference in New Issue
Block a user