forked from OurHike/OurHike
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathReportForm.tsx
More file actions
244 lines (222 loc) · 8.85 KB
/
Copy pathReportForm.tsx
File metadata and controls
244 lines (222 loc) · 8.85 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
// The report form (WIREFRAMES.md §6), shared by condition reports and by a
// thanks - which is a report type, not a separate model
// (features/SAYING_THANKS.md).
//
// The authoring time is taken at MOUNT, not at submit. Someone can start a
// report, walk on, and finish it twenty minutes later; and an offline report
// may not send for days. What matters is when they saw the thing, so that is
// the moment recorded - matching the `authored_at` field the reports API
// accepts and the authored timestamp the outbox carries.
//
// Nothing here blocks on network. Submitting while offline queues the report
// and says so, because on this trail that is the ordinary path.
import { useState } from 'react'
import type { ReportDraft } from '../lib/outbox'
import { PhotoUnusable, prepareReportPhoto } from '../lib/reportPhoto'
import './reporting.css'
export type ReportFormType = ReportDraft['type']
const TITLES: Record<ReportFormType, string> = {
blowdown: 'Blow down',
flooding: 'Flooding',
trash: 'Trash',
shelter_repair: 'Shelter repair',
animals: 'Animals',
invasive_species: 'Invasive species',
bad_hikers: 'Something unsafe happened',
thanks: 'Say thanks',
}
export interface ReportFormLocation {
lat: number
lon: number
/**
* Omitted when the fix cannot be placed on the centerline - off the trail,
* or before the trail index has been downloaded. The coordinates are still
* worth sending; only the mile is unknown.
*/
mile?: number
}
export interface ReportFormSubmission extends ReportDraft {
authoredAt: Date
/** The prepared JPEG, if one was attached - bytes rather than a URL, see
* `OutboxItem.photo`. Absent is the ordinary case. */
photo?: Blob
}
/**
* What the form says about where the report will land.
*
* "mi 0.0" is Springer Mountain and 0,0 is the Atlantic off West Africa, so
* neither is a stand-in for "we don't know yet" - this is the same rule the
* header already keeps about the mile readout (chrome/Header.tsx). A
* maintainer reading a queue of blowdowns needs to be able to tell the reports
* with a place from the ones without, and both wrong answers hide that.
*/
function describeLocation(location: ReportFormLocation | null): string {
if (location === null) return 'No GPS fix — this report will have no location'
if (location.mile === undefined)
return 'Location saved, but not matched to a trail mile'
return `mi ${location.mile.toLocaleString('en-US', {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})}`
}
export interface ReportFormProps {
type: ReportFormType
trailName: string | null
reporterType: ReportDraft['reporter_type']
/** Null when there is no GPS fix at all - see the note above. */
location: ReportFormLocation | null
onSubmit: (submission: ReportFormSubmission) => void
onCancel: () => void
online?: boolean
/** One line naming who looks after this stretch; only for a thanks. */
stewards?: string | null
/** Injectable so the authoring stamp is testable. */
now?: Date
}
export function ReportForm({
type,
trailName,
reporterType,
location,
onSubmit,
onCancel,
online = true,
stewards = null,
now,
}: ReportFormProps) {
// Captured once, on mount - see the note above.
const [authoredAt] = useState(() => now ?? new Date())
const [note, setNote] = useState('')
const [photo, setPhoto] = useState<Blob | null>(null)
const [preparing, setPreparing] = useState(false)
const [photoError, setPhotoError] = useState<string | null>(null)
/**
* Shrink and re-encode the picked file, or say why it cannot be sent.
*
* Clearing the previous photo before starting is deliberate: a second pick
* that fails must not leave the first one silently attached, which would
* send a photo the hiker believes they replaced.
*/
const choosePhoto = async (file: File | null) => {
setPhoto(null)
setPhotoError(null)
if (file === null) return
setPreparing(true)
try {
setPhoto(await prepareReportPhoto(file))
} catch (error) {
// The message is written for a hiker to read (lib/reportPhoto.ts);
// anything else that got this far is not, so it does not get shown.
setPhotoError(
error instanceof PhotoUnusable
? error.message
: 'That photo could not be prepared. Try taking another.',
)
} finally {
setPreparing(false)
}
}
const isThanks = type === 'thanks'
return (
<main className="reporting">
<h1 className="reporting__title">{TITLES[type]}</h1>
{isThanks && stewards !== null && <p className="reporting__stewards">{stewards}</p>}
<label className="reporting__field">
<span className="reporting__field-label">
{isThanks ? 'What made the difference?' : 'Note'}
</span>
<textarea
className="reporting__note"
value={note}
rows={4}
onChange={(event) => setNote(event.target.value)}
/>
</label>
{/* The photo field, working at last (#234).
It was a real `<input type="file">` with no onChange and no state
behind it, so a hiker photographing a washed-out bridge got a control
that accepted the photo and a report that arrived without it. #89
disabled it and said so rather than removing it, on the grounds that
somebody who took a photo specifically to attach would otherwise
wonder whether they had missed the button. This is the wiring that
note was waiting for.
The shrink happens HERE, on pick, rather than during a flush that may
be days away with the phone in a pocket. That is the whole reason:
the only failure a hiker can do anything about is "take another one",
and they can only do that while still standing in front of the thing
they photographed. */}
<label className="reporting__field">
<span className="reporting__field-label">Photo</span>
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/heic"
className="reporting__photo"
onChange={(event) => void choosePhoto(event.target.files?.[0] ?? null)}
aria-describedby={photoError === null ? undefined : 'photo-error'}
/>
{preparing && (
<span className="reporting__meta" role="status">
Shrinking the photo…
</span>
)}
{photoError !== null && (
<span className="reporting__unavailable" id="photo-error" role="alert">
{photoError}
</span>
)}
{photo !== null && !preparing && (
<span className="reporting__meta">
{`Photo attached — ${Math.round(photo.size / 1024)} KB. Location and camera details are not included.`}
</span>
)}
</label>
<p className="reporting__meta">{describeLocation(location)}</p>
<p className="reporting__meta">
{`Signed as ${trailName ?? 'not set'} · ${reporterType}`}
</p>
{!online && (
<p className="reporting__queued" role="status">
No signal — this will wait in your outbox and sync later, keeping the time you
wrote it.
</p>
)}
<div className="reporting__actions">
<button
type="button"
className="reporting__primary"
// Disabled only while the shrink is running, and only then. A photo
// that failed leaves the button live on purpose: the note is what
// carries the report, and refusing to send it because the picture
// did not work would lose the words over the image.
disabled={preparing}
onClick={() =>
onSubmit({
type,
reporter_type: reporterType,
note: note.trim() === '' ? undefined : note.trim(),
// Both omitted rather than zeroed with no fix. The reports API
// takes lat and lon as optional for exactly this case, and a
// report pinned at 0,0 is not a report with a missing location -
// it is a report at a confident, wrong place in the Atlantic.
lat: location?.lat,
lon: location?.lon,
// The mile this form has been computing all along, and used to
// throw away here (#244). It is the value the serious-warnings
// banner filters on, and nothing server-side can re-derive it -
// the backend holds no centerline. Same omitted-not-zeroed rule
// as the coordinates: mi 0 is Springer Mountain, not "unknown".
mile: location?.mile,
authoredAt,
...(photo !== null ? { photo } : {}),
})
}
>
{online ? 'Send' : 'Save to outbox'}
</button>
<button type="button" className="reporting__secondary" onClick={onCancel}>
Cancel
</button>
</div>
</main>
)
}