mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
refactor(collections): share the add place to list logic between desktop and phone
useAddPlaceToCollection holds the maps search, the pick and the save that AddPlaceToCollectionModal and MCollAddSheet each carried, with the pure helpers in addPlaceModel. The differences stay as variants. The desktop resets and stays open after an add, sends links and typed coordinates and shows the error's own text. The phone closes, takes coordinates only from the picked hit, ignores a second search or save while one is running and shows the fallback text.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useId, useRef, useState } from 'react'
|
||||
import React, { useId, useRef } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
@@ -8,20 +8,12 @@ import { AddRowButton, EditorField, GRID_2, INPUT, PANEL, Segmented, TEXTAREA }
|
||||
import { NumericInput } from '../shared/NumericInput'
|
||||
import NoteFormatToolbar from '../shared/NoteFormatToolbar'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { collectionsApi } from '../../api/collections'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import { normalizeLinkUrl, STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
|
||||
import type { Category, TranslationFn } from '../../types'
|
||||
import type { CollectionLink, CollectionStatus } from '@trek/shared'
|
||||
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage'
|
||||
|
||||
type MapsPlace = Record<string, unknown>
|
||||
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined)
|
||||
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : typeof v === 'string' && v !== '' ? Number(v) : undefined)
|
||||
import type { CollectionStatus } from '@trek/shared'
|
||||
import { str } from './addPlaceModel'
|
||||
import { useAddPlaceToCollection } from './useAddPlaceToCollection'
|
||||
|
||||
/**
|
||||
* The search row, pinned to the top of the scrolling body. The dialog stays open
|
||||
@@ -54,114 +46,23 @@ interface AddPlaceToCollectionModalProps {
|
||||
* added in a row.
|
||||
*/
|
||||
export default function AddPlaceToCollectionModal({ isOpen, collectionId, collectionName, categories, onClose, onAdded, t }: AddPlaceToCollectionModalProps): React.ReactElement {
|
||||
const { language } = useTranslation()
|
||||
const placeLang = usePlaceLanguage()
|
||||
const toast = useToast()
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<MapsPlace[]>([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
// A search that came back empty used to render nothing at all, which reads as
|
||||
// "the dialog is dead". Say so instead (#1921).
|
||||
const [noResults, setNoResults] = useState(false)
|
||||
// The picked location (address/coords/ids) plus the editable fields.
|
||||
const [picked, setPicked] = useState<MapsPlace | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
// Address + coordinates: prefilled from a picked result, but also directly
|
||||
// typeable so a place can be added by GPS without searching (#1435).
|
||||
const [address, setAddress] = useState('')
|
||||
const [lat, setLat] = useState('')
|
||||
const [lng, setLng] = useState('')
|
||||
const [categoryId, setCategoryId] = useState<number | null>(null)
|
||||
const [description, setDescription] = useState('')
|
||||
const [links, setLinks] = useState<CollectionLink[]>([])
|
||||
const [status, setStatus] = useState<CollectionStatus>('idea')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const descRef = useRef<HTMLTextAreaElement>(null)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
const {
|
||||
query, setQuery, results, searching, noResults, setNoResults, dismissResults, name, setName, address, setAddress,
|
||||
lat, setLat, lng, setLng, categoryId, setCategoryId, description, setDescription, links, setLinks, setLink,
|
||||
status, setStatus, saving, search, pick, coordPaste, save,
|
||||
} = useAddPlaceToCollection({
|
||||
variant: 'dialog', open: isOpen, collectionId, collectionName, t, onClose, onAdded, normalizeLinkUrl,
|
||||
// The dialog stays open for the next place, so hand the caret back to the
|
||||
// search field. The add button the user just clicked goes disabled with the
|
||||
// cleared name, which drops the focus to <body> and leaves the dialog dead
|
||||
// to the keyboard (#1921).
|
||||
afterAdd: () => searchRef.current?.focus(),
|
||||
})
|
||||
const descRef = useRef<HTMLTextAreaElement>(null)
|
||||
const labelId = useId()
|
||||
const fieldId = useId()
|
||||
|
||||
const reset = () => { setQuery(''); setResults([]); setNoResults(false); setPicked(null); setName(''); setAddress(''); setLat(''); setLng(''); setCategoryId(null); setDescription(''); setLinks([]); setStatus('idea') }
|
||||
useEffect(() => { if (!isOpen) reset() }, [isOpen])
|
||||
|
||||
const search = async () => {
|
||||
if (!query.trim()) return
|
||||
setSearching(true)
|
||||
setNoResults(false)
|
||||
try {
|
||||
const res = await mapsApi.search(query, placeLang)
|
||||
const places = (res.places as MapsPlace[]) || []
|
||||
setResults(places)
|
||||
setNoResults(places.length === 0)
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const dismissResults = () => { setResults([]); setNoResults(false) }
|
||||
|
||||
const pick = (r: MapsPlace) => {
|
||||
setPicked(r)
|
||||
setName(str(r.name) ?? '')
|
||||
setAddress(str(r.address) ?? '')
|
||||
const la = num(r.lat); const lo = num(r.lng)
|
||||
setLat(la != null ? String(la) : '')
|
||||
setLng(lo != null ? String(lo) : '')
|
||||
setResults([]); setNoResults(false); setQuery(str(r.name) ?? query)
|
||||
}
|
||||
const setLink = (i: number, patch: Partial<CollectionLink>) => setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
|
||||
|
||||
const save = async () => {
|
||||
const cleanName = name.trim()
|
||||
if (!cleanName) return
|
||||
const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url)
|
||||
const latNum = lat.trim() ? Number(lat) : Number.NaN
|
||||
const lngNum = lng.trim() ? Number(lng) : Number.NaN
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await collectionsApi.savePlace({
|
||||
collection_id: collectionId,
|
||||
name: cleanName,
|
||||
address: address.trim() || null,
|
||||
lat: Number.isFinite(latNum) ? latNum : null,
|
||||
lng: Number.isFinite(lngNum) ? lngNum : null,
|
||||
google_place_id: (picked && str(picked.google_place_id)) ?? null,
|
||||
google_ftid: (picked && str(picked.google_ftid)) ?? null,
|
||||
osm_id: (picked && str(picked.osm_id)) ?? null,
|
||||
website: (picked && str(picked.website)) ?? null,
|
||||
phone: (picked && str(picked.phone)) ?? null,
|
||||
category_id: categoryId,
|
||||
description: description.trim() || null,
|
||||
links: cleanLinks,
|
||||
status,
|
||||
force: true,
|
||||
})
|
||||
if (res.duplicate) toast.info(t('collections.duplicateWarning'))
|
||||
else { toast.success(t('collections.addedToList', { name: collectionName })); onAdded() }
|
||||
reset()
|
||||
// The dialog stays open for the next place, so hand the caret back to the
|
||||
// search field. The add button the user just clicked goes disabled with the
|
||||
// cleared name, which drops the focus to <body> and leaves the dialog dead
|
||||
// to the keyboard (#1921).
|
||||
searchRef.current?.focus()
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const coordPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const text = e.clipboardData.getData('text').trim()
|
||||
// Same pairs as before, written so no two quantifiers can claim the same
|
||||
// character. In the old form `\d+\.?\d*` and `\s*[,;\s]\s*` were both ambiguous,
|
||||
// which backtracks in O(n^4): a pasted 2 kB of digits and spaces froze the tab.
|
||||
const match = text.match(/^(-?\d+(?:\.\d*)?)(?:\s*[,;]\s*|\s+)(-?\d+(?:\.\d*)?)$/)
|
||||
if (match) { e.preventDefault(); setLat(match[1]); setLng(match[2]) }
|
||||
}
|
||||
|
||||
const header = (
|
||||
<DialogHeader
|
||||
tile={<DialogTile><MapPinned size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
||||
|
||||
Reference in New Issue
Block a user