mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
feat: quality of life sweep from the feature discussions
Small requests from the discussions, each built end to end: planner and map (cycling mode, date first headings, clear a day, stay times per day, off-route stops, map lock, compact unplanned markers, pending stays, cable car, transit quick picks), places (location filter, category from OSM, open in maps, geo: link, image from an attachment, enrichment on file import, places near a pin, place name language, Amap first in China), lists and costs (packed counter, inherited sharing, Markdown to-dos, payment notes, bookings CSV, my plan PDF), sharing (unplanned places, travel only, without photos), journey (photo zoom, drafts, photo order, trip suggestion, day jump), atlas (region counter, region bucket list, places per country), vacay half company holidays, studio copy and paste plus the book's own layouts, and RESTORE_FROM_BACKUP for a first start restore.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
// FE-SHARED-MDEDIT-001 to FE-SHARED-MDEDIT-004 (#2558)
|
||||
import { useState } from 'react'
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
import MarkdownEditable from './MarkdownEditable'
|
||||
|
||||
function Harness({ initial, canEdit = true }: { initial: string; canEdit?: boolean }) {
|
||||
const [value, setValue] = useState(initial)
|
||||
return (
|
||||
<MarkdownEditable value={value} canEdit={canEdit} editLabel="Edit"
|
||||
renderEditor={editor => <textarea aria-label="editor" value={value} onChange={e => setValue(e.target.value)} {...editor} />} />
|
||||
)
|
||||
}
|
||||
|
||||
describe('MarkdownEditable', () => {
|
||||
it('FE-SHARED-MDEDIT-001: renders a saved value as Markdown with a working link', () => {
|
||||
render(<Harness initial={'**Ferry** at [the port](https://example.com)'} />)
|
||||
expect(screen.getByText('Ferry').tagName).toBe('STRONG')
|
||||
const link = screen.getByRole('link', { name: 'the port' })
|
||||
expect(link).toHaveAttribute('href', 'https://example.com')
|
||||
// Following the link does not open the editor.
|
||||
fireEvent.click(link)
|
||||
expect(screen.queryByLabelText('editor')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-SHARED-MDEDIT-002: a click on the text opens the editor, leaving it shows the text again', () => {
|
||||
render(<Harness initial="Bring cash" />)
|
||||
fireEvent.click(screen.getByText('Bring cash'))
|
||||
const editor = screen.getByLabelText('editor')
|
||||
expect(editor).toHaveValue('Bring cash')
|
||||
fireEvent.blur(editor)
|
||||
expect(screen.queryByLabelText('editor')).not.toBeInTheDocument()
|
||||
fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' })
|
||||
expect(screen.getByLabelText('editor')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-SHARED-MDEDIT-003: an empty field is the editor, and stays one while it is typed into', () => {
|
||||
render(<Harness initial="" />)
|
||||
const editor = screen.getByLabelText('editor')
|
||||
fireEvent.focus(editor)
|
||||
fireEvent.change(editor, { target: { value: 'N' } })
|
||||
expect(screen.getByLabelText('editor')).toHaveValue('N')
|
||||
})
|
||||
|
||||
it('FE-SHARED-MDEDIT-004: without edit rights it is only text', () => {
|
||||
render(<Harness initial="Read only" canEdit={false} />)
|
||||
expect(screen.getByText('Read only')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useState, type CSSProperties, type ReactNode } from 'react'
|
||||
import MarkdownText from './MarkdownText'
|
||||
import { Tooltip } from './Tooltip'
|
||||
|
||||
interface EditorProps {
|
||||
autoFocus: boolean
|
||||
/** Holds the editor open while it has focus, so typing into an empty field does not flip it to text. */
|
||||
onFocus: () => void
|
||||
onBlur: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* A Markdown field that reads as rendered text and turns into its textarea on a
|
||||
* click (#2558): a link in a to-do description is a link, not a line of syntax.
|
||||
* An empty field, or one being written, is the editor straight away. A click that
|
||||
* lands on a link follows the link and leaves the text alone.
|
||||
*/
|
||||
export default function MarkdownEditable({ value, canEdit, editLabel, renderEditor, className = '', style }: {
|
||||
value: string
|
||||
canEdit: boolean
|
||||
/** The hint on the rendered text, which is what opens the editor. */
|
||||
editLabel: string
|
||||
renderEditor: (props: EditorProps) => ReactNode
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
if (canEdit && (editing || !value.trim())) {
|
||||
return <>{renderEditor({ autoFocus: editing, onFocus: () => setEditing(true), onBlur: () => setEditing(false) })}</>
|
||||
}
|
||||
if (!canEdit) return <MarkdownText className={className}>{value}</MarkdownText>
|
||||
return (
|
||||
<Tooltip label={editLabel} placement="top">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={e => { if (!(e.target as HTMLElement).closest('a')) setEditing(true) }}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && e.target === e.currentTarget) { e.preventDefault(); setEditing(true) } }}
|
||||
className={`cursor-text ${className}`}
|
||||
style={style}
|
||||
>
|
||||
<MarkdownText>{value}</MarkdownText>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Navigation } from 'lucide-react'
|
||||
import type { NavigationTarget } from '../Planner/placeNavigation'
|
||||
import { openNavigationTarget } from '../Planner/placeNavigation'
|
||||
import { navigationTargetLabel, openNavigationTarget } from '../Planner/placeNavigation'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
interface NavigationMenuProps {
|
||||
targets: NavigationTarget[]
|
||||
@@ -27,6 +28,7 @@ const EDGE = 8
|
||||
* than the space to its right is pulled back inside.
|
||||
*/
|
||||
export function NavigationMenu({ targets, anchor, onClose, title }: NavigationMenuProps) {
|
||||
const { t } = useTranslation()
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
|
||||
|
||||
@@ -126,7 +128,7 @@ export function NavigationMenu({ targets, anchor, onClose, title }: NavigationMe
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'none' }}
|
||||
>
|
||||
<Navigation size={13} style={{ flexShrink: 0, color: 'var(--text-faint)' }} />
|
||||
<span>{target.label}</span>
|
||||
<span>{navigationTargetLabel(target, t)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { Camera, Loader2, X } from 'lucide-react'
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Camera, Loader2, Upload, X } from 'lucide-react'
|
||||
import PlaceAvatar from './PlaceAvatar'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import { normalizeImageFile } from '../../utils/convertHeic'
|
||||
import { useToast } from './Toast'
|
||||
import { useTranslation, translateApiError } from '../../i18n'
|
||||
import type { Place } from '../../types'
|
||||
import { fetchImageAsBlob } from '../../api/authUrl'
|
||||
import type { Place, TripFile } from '../../types'
|
||||
|
||||
interface Category {
|
||||
color?: string
|
||||
@@ -19,6 +21,16 @@ interface PlaceAvatarUploadProps {
|
||||
onUpload: (file: File) => Promise<void>
|
||||
/** Clears the custom image; the auto-fetched default thumbnail then returns (#1136). */
|
||||
onRemove: () => Promise<void> | void
|
||||
/** Pictures already attached to the place, offered as its image beside an upload (#1242). */
|
||||
attachedImages?: TripFile[]
|
||||
onPickAttached?: (fileId: number) => Promise<void>
|
||||
}
|
||||
|
||||
const PICKABLE = /^image\/(jpeg|png|gif|webp)$/
|
||||
|
||||
/** The images among a place's files that can stand as its picture. */
|
||||
export function pickableImages(files: TripFile[]): TripFile[] {
|
||||
return files.filter(f => PICKABLE.test(f.mime_type || ''))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,12 +38,29 @@ interface PlaceAvatarUploadProps {
|
||||
* a camera overlay with a tooltip, clicking opens the file picker, and when a custom
|
||||
* upload is present a small corner button removes it (falling back to the default).
|
||||
*/
|
||||
export default function PlaceAvatarUpload({ place, category, size = 52, onUpload, onRemove }: PlaceAvatarUploadProps) {
|
||||
export default function PlaceAvatarUpload({ place, category, size = 52, onUpload, onRemove, attachedImages = [], onPickAttached }: PlaceAvatarUploadProps) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [chooserOpen, setChooserOpen] = useState(false)
|
||||
const hasCustom = Boolean(place.image_url)
|
||||
const offersAttached = !!onPickAttached && attachedImages.length > 0
|
||||
// With pictures on the place the click asks where from; without, it goes straight to the device.
|
||||
const openPicker = () => { if (busy) return; if (offersAttached) setChooserOpen(o => !o); else fileRef.current?.click() }
|
||||
|
||||
const pickAttached = async (fileId: number) => {
|
||||
setChooserOpen(false)
|
||||
setBusy(true)
|
||||
try {
|
||||
await onPickAttached!(fileId)
|
||||
} catch (err: unknown) {
|
||||
toast.error(translateApiError(t, err, 'places.imageUploadError'))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
@@ -65,10 +94,13 @@ export default function PlaceAvatarUpload({ place, category, size = 52, onUpload
|
||||
<div
|
||||
className="group"
|
||||
style={{ position: 'relative', width: size, height: size, borderRadius: '50%', cursor: busy ? 'default' : 'pointer' }}
|
||||
onClick={() => { if (!busy) fileRef.current?.click() }}
|
||||
ref={anchorRef}
|
||||
onClick={openPicker}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => { if ((e.key === 'Enter' || e.key === ' ') && !busy) { e.preventDefault(); fileRef.current?.click() } }}
|
||||
aria-haspopup={offersAttached ? 'dialog' : undefined}
|
||||
aria-expanded={offersAttached ? chooserOpen : undefined}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPicker() } }}
|
||||
aria-label={hasCustom ? t('places.changeImage') : t('places.uploadImage')}
|
||||
>
|
||||
<PlaceAvatar place={place} category={category} size={size} />
|
||||
@@ -106,6 +138,81 @@ export default function PlaceAvatarUpload({ place, category, size = 52, onUpload
|
||||
)}
|
||||
|
||||
<input ref={fileRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp,.heic,.heif" style={{ display: 'none' }} onChange={handleFile} />
|
||||
{chooserOpen && (
|
||||
<ImageChooser
|
||||
anchor={anchorRef.current}
|
||||
images={attachedImages}
|
||||
onUpload={() => { setChooserOpen(false); fileRef.current?.click() }}
|
||||
onPick={id => { void pickAttached(id) }}
|
||||
onClose={() => setChooserOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Upload from the device, or one of the pictures already on the place (#1242). */
|
||||
function ImageChooser({ anchor, images, onUpload, onPick, onClose }: {
|
||||
anchor: HTMLElement | null
|
||||
images: TripFile[]
|
||||
onUpload: () => void
|
||||
onPick: (fileId: number) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!anchor) return
|
||||
const r = anchor.getBoundingClientRect()
|
||||
const width = 264
|
||||
setPos({ top: r.bottom + 8, left: Math.min(Math.max(12, r.left), window.innerWidth - width - 12) })
|
||||
}, [anchor])
|
||||
|
||||
useEffect(() => {
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node
|
||||
if (!ref.current?.contains(target) && !anchor?.contains(target)) onClose()
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey) }
|
||||
}, [anchor, onClose])
|
||||
|
||||
if (!pos) return null
|
||||
return createPortal(
|
||||
<div ref={ref} role="dialog" aria-label={t('places.chooseImage')}
|
||||
className="trek-popover-enter fixed z-[var(--z-toast)] flex w-[264px] flex-col gap-2 rounded-[14px] border border-edge-secondary bg-surface-card p-2 shadow-popover"
|
||||
style={{ top: pos.top, left: pos.left }}>
|
||||
<button type="button" onClick={onUpload}
|
||||
className="flex items-center gap-2 rounded-[10px] px-2.5 py-2 text-left text-body font-semibold text-content hover:bg-surface-hover">
|
||||
<Upload size={14} strokeWidth={2.2} className="text-content-muted" />
|
||||
{t('places.uploadFromDevice')}
|
||||
</button>
|
||||
<div className="px-2.5 text-caption font-semibold uppercase tracking-[0.04em] text-content-faint">{t('places.fromAttachedFiles')}</div>
|
||||
<div className="grid grid-cols-4 gap-1.5 px-1 pb-1">
|
||||
{images.map(f => <AttachedThumb key={f.id} file={f} onPick={() => onPick(f.id)} />)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
function AttachedThumb({ file, onPick }: { file: TripFile; onPick: () => void }) {
|
||||
const [src, setSrc] = useState<string | null>(null)
|
||||
useEffect(() => {
|
||||
let current = true
|
||||
fetchImageAsBlob(file.url).then(u => { if (current) setSrc(u) }).catch(() => {})
|
||||
return () => { current = false }
|
||||
}, [file.url])
|
||||
return (
|
||||
<Tooltip label={file.original_name} placement="top">
|
||||
<button type="button" onClick={onPick} aria-label={file.original_name}
|
||||
className="aspect-square overflow-hidden rounded-[9px] bg-surface-secondary ring-1 ring-edge-faint transition-shadow hover:ring-2 hover:ring-accent">
|
||||
{src && <img src={src} alt="" className="h-full w-full object-cover" />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user