mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(journey): let the desktop editor set the entry time, and reach the camera
The time was already stored, sent to the server and shown in the timeline, on the map, in the PDF and on the public share — the desktop editor just never rendered an input for it. So a draft carried the wall-clock time it was created at, it showed up everywhere, and there was no way to correct it (#1614). The column holds HH:MM:SS and a time input wants HH:MM, so both the initial value and the dirty check now trim. The camera gets its own hidden input rather than a capture attribute on the existing picker: putting it there would take the photo library away on a phone, which is the more common way in. The phone sheet already works this way.
This commit is contained in:
@@ -836,4 +836,46 @@ describe('EntryEditor', () => {
|
||||
expect(amazing.className).toContain('border-zinc-200')
|
||||
expect(amazing.getAttribute('style')).toBe('')
|
||||
})
|
||||
|
||||
// #1614 — entry_time was stored, sent and displayed everywhere, but the desktop
|
||||
// editor never rendered an input for it.
|
||||
it('FE-JRN-EDITOR-041: shows the stored time and saves an edited one', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSave } = mountEditor(buildEntry({ id: 10, title: 'Old', entry_time: '14:30:00' }))
|
||||
|
||||
// The column carries HH:MM:SS; a time input only accepts HH:MM.
|
||||
const timeInput = screen.getByDisplayValue('14:30')
|
||||
expect(timeInput).toHaveAttribute('type', 'time')
|
||||
|
||||
fireEvent.change(timeInput, { target: { value: '09:05' } })
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled())
|
||||
expect(onSave.mock.calls[0][0]).toEqual(expect.objectContaining({ entry_time: '09:05' }))
|
||||
})
|
||||
|
||||
it('FE-JRN-EDITOR-042: clearing the time sends null rather than an empty string', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSave } = mountEditor(buildEntry({ id: 10, entry_time: '14:30:00' }))
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('14:30'), { target: { value: '' } })
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled())
|
||||
expect(onSave.mock.calls[0][0]).toEqual(expect.objectContaining({ entry_time: null }))
|
||||
})
|
||||
|
||||
it('FE-JRN-EDITOR-043: the camera route is its own input, so the library picker survives', () => {
|
||||
const { container } = mountEditor(buildEntry({ id: 10 }))
|
||||
|
||||
const inputs = Array.from(container.querySelectorAll('input[type="file"]'))
|
||||
const camera = inputs.filter(i => i.getAttribute('capture') === 'environment')
|
||||
const library = inputs.filter(i => !i.hasAttribute('capture'))
|
||||
|
||||
expect(camera).toHaveLength(1)
|
||||
expect(library).toHaveLength(1)
|
||||
// The library picker keeps multi-select; forcing capture onto it would drop that.
|
||||
expect(library[0]).toHaveAttribute('multiple')
|
||||
expect(camera[0]).not.toHaveAttribute('multiple')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { X, Plus, Image, Minus, Check, MapPin, Locate } from 'lucide-react'
|
||||
import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera } from 'lucide-react'
|
||||
import { normalizeImageFiles } from '../../utils/convertHeic'
|
||||
import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -34,7 +34,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
const [title, setTitle] = useState(entry.title || '')
|
||||
const [story, setStory] = useState(entry.story || '')
|
||||
const [entryDate, setEntryDate] = useState(entry.entry_date || new Date().toISOString().split('T')[0])
|
||||
const [entryTime, setEntryTime] = useState(entry.entry_time || '')
|
||||
const [entryTime, setEntryTime] = useState(entry.entry_time?.slice(0, 5) || '')
|
||||
const [locationName, setLocationName] = useState(entry.location_name || '')
|
||||
const [locationLat, setLocationLat] = useState<number | null>(entry.location_lat ?? null)
|
||||
const [locationLng, setLocationLng] = useState<number | null>(entry.location_lng ?? null)
|
||||
@@ -60,6 +60,10 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
const [externalProvider, setExternalProvider] = useState<string | null>(null)
|
||||
const [pendingProviderGroups, setPendingProviderGroups] = useState<PendingProviderGroup[]>([])
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
// Own input: putting `capture` on the picker above would take the photo library
|
||||
// away on a phone, which is the more common way in. This one only ever opens the
|
||||
// camera, so tablets and laptops get the same route the phone sheet already has.
|
||||
const cameraRef = useRef<HTMLInputElement>(null)
|
||||
const storyRef = useRef<HTMLTextAreaElement>(null)
|
||||
const persistedEntryIdRef = useRef<number | null>(entry.id > 0 ? entry.id : null)
|
||||
|
||||
@@ -71,7 +75,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
title !== (entry.title || '') ||
|
||||
story !== (entry.story || '') ||
|
||||
entryDate !== (entry.entry_date || new Date().toISOString().split('T')[0]) ||
|
||||
entryTime !== (entry.entry_time || '') ||
|
||||
entryTime !== (entry.entry_time?.slice(0, 5) || '') ||
|
||||
locationName !== (entry.location_name || '') ||
|
||||
(locationLat ?? null) !== (entry.location_lat ?? null) ||
|
||||
(locationLng ?? null) !== (entry.location_lng ?? null) ||
|
||||
@@ -258,6 +262,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
|
||||
<div>
|
||||
<input ref={fileRef} type="file" accept="image/*" multiple onChange={handleFileChange} onClick={e => { (e.target as HTMLInputElement).value = '' }} className="hidden" />
|
||||
<input ref={cameraRef} type="file" accept="image/*" capture="environment" onChange={handleFileChange} onClick={e => { (e.target as HTMLInputElement).value = '' }} className="hidden" />
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setPhotoTab('upload'); setShowGalleryPick(false); fileRef.current?.click() }}
|
||||
@@ -282,6 +287,15 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
<Image size={13} /> {t('journey.editor.fromGallery')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setPhotoTab('upload'); setShowGalleryPick(false); cameraRef.current?.click() }}
|
||||
disabled={saving}
|
||||
aria-label={t('journey.photo.add')}
|
||||
title={t('journey.photo.add')}
|
||||
className="border border-dashed border-zinc-200 dark:border-zinc-700 rounded-xl px-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center disabled:opacity-50"
|
||||
>
|
||||
<Camera size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setPhotoTab('external'); setShowGalleryPick(false) }}
|
||||
disabled={saving}
|
||||
@@ -559,9 +573,23 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.editor.date')}</label>
|
||||
<DatePicker value={entryDate} onChange={setEntryDate} tripDates={tripDates} />
|
||||
{/* Time sat in state and went to the server, it just had no input here — so a
|
||||
draft's auto-stamped clock time showed up in the timeline, the map, the PDF
|
||||
and the public share, and the desktop had no way to correct it (#1614). */}
|
||||
<div className="grid grid-cols-[1fr_104px] gap-2">
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.editor.date')}</label>
|
||||
<DatePicker value={entryDate} onChange={setEntryDate} tripDates={tripDates} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('mobileJourney.time')}</label>
|
||||
<input
|
||||
type="time"
|
||||
value={entryTime}
|
||||
onChange={e => setEntryTime(e.target.value)}
|
||||
className="w-full h-[42px] px-3 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-transparent text-[13px] text-zinc-900 dark:text-white outline-none focus:border-zinc-400 dark:focus:border-zinc-500 [font-variant-numeric:tabular-nums]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.editor.location')}</label>
|
||||
|
||||
Reference in New Issue
Block a user