ReviewOS

also looking at this

stacks/wildloop

feat: native trail maps and complete native mobile screens

#1017
Draft chris wants to merge codex/trail-experience into main
60 files +2505 -823

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

resources/native/Record.stxmodified+1-1
Changes to resources/native/Record.stx
@@ -146,7 +146,7 @@ function togglePause() { void perform(async () => {
146146async function upload() {
147147 const value=recording()
148148 if (!value?.pending) return
149 const api=createNativeApi(craft)
149 const api=await createNativeApi(craft).bindSession()
150150 const me=await api.send('/api/me')
151151 if (!me.ok || Number(me.body?.user?.id) !== value.userId) throw new Error('Log in as the account that started this recording before uploading it.')
152152 const response=await api.send('/api/activities','POST',value.pending)
resources/native/TrailDetail.stxmodified+47-20
Changes to resources/native/TrailDetail.stx
@@ -1,5 +1,6 @@
11<script client lang="ts">
22import type { NativeCraft } from '@stacksjs/stx/native'
3import type { NativeApi } from '../functions/native-api'
34import { onNativeAccountChange, createNativeApi, loadOutcome, nativeApiOrigin } from '../functions/native-api'
45import { nativeBasemap, nativeBasemapOptions } from '../functions/native-basemaps'
56import { chooseNativeChoice, nativeChoiceLabel } from '../functions/native-choice'
@@ -58,6 +59,7 @@ const formError = state('')
5859const photoDrafts = state<Array<{file:any,id?:string}>>([])
5960let alive = true
6061let generation = 0
62let accountGeneration = 0
6163
6264const surfaces = derived(() => trailSurfaceTypes(rawTrail()?.surface))
6365const routeAvailable = derived(() => parts().some(part => part.length >= 4))
@@ -66,6 +68,7 @@ const mapRegion = derived(() => region() ?? nativeRouteRegion(parts(), {lat:trai
6668const photos = derived(() => trailGallery(trail(),reviews()))
6769
6870function textOf(event: any): string { return String(event?.text ?? '') }
71function accountIsCurrent(version:number): boolean { return alive && version === accountGeneration }
6972function profilePath(): string { return trailProfilePath(profile()) }
7073function photoUrls(row: any): string[] { return parsePhotoList(row.photos).map(url=>url.startsWith('/') ? nativeApiOrigin(craft)+url : url) }
7174function photoUrl(url:string):string {return url.startsWith('/') ? nativeApiOrigin(craft)+url : url}
@@ -119,15 +122,20 @@ function signedIn(): boolean {
119122}
120123async function toggleSaved() {
121124 if (loading() || !viewerReady() || busy() || !signedIn()) return
125 const current = accountGeneration
126 const method = saved() ? 'DELETE' : 'PUT'
122127 busy.set(true)
123128 try {
124 const response = await api.send(`/api/trails/${wanted}/save`, saved() ? 'DELETE' : 'PUT')
129 const operation = await api.bindSession()
130 if (!accountIsCurrent(current)) return
131 const response = await operation.send(`/api/trails/${wanted}/save`, method)
132 if (!accountIsCurrent(current)) return
125133 if (!response.ok) throw new Error(response.body?.error || 'The trail could not be saved.')
126134 saved.set(Boolean(response.body.saved))
127135 notice.set(saved() ? 'Trail saved.' : 'Trail removed from saved trails.')
128136 }
129 catch (cause) { notice.set(cause instanceof Error ? cause.message : 'The trail could not be saved.') }
130 finally { busy.set(false) }
137 catch (cause) { if (accountIsCurrent(current)) notice.set(cause instanceof Error ? cause.message : 'The trail could not be saved.') }
138 finally { if (accountIsCurrent(current)) busy.set(false) }
131139}
132140function editReview() {
133141 if (loading() || !viewerReady() || busy() || !signedIn()) return
@@ -141,73 +149,85 @@ function editReview() {
141149 photoDrafts.set([])
142150 composer.set(true)
143151}
144async function reloadReviews() {
152async function reloadReviews(operation: NativeApi) {
145153 const current = generation
146154 viewerReady.set(false)
147 const [response, personal] = await Promise.all([api.send(`/api/trails/${wanted}/reviews`), api.send(`/api/trails/${wanted}/viewer`)])
155 const [response, personal] = await Promise.all([operation.send(`/api/trails/${wanted}/reviews`), operation.send(`/api/trails/${wanted}/viewer`)])
148156 if (!alive || current !== generation) return
149157 if (personal.ok) { ownReview.set(personal.body.review ?? null); viewerReady.set(true) }
150158 if (response.ok) reviews.set(response.body.reviews ?? [])
151159 if (!response.ok || !personal.ok) throw new Error('Your change was saved, but reviews could not refresh. Pull down to retry.')
152160}
153161async function pickReviewPhoto() {
154 if (busy()) return
162 if (loading() || !viewerReady() || busy() || !signedIn()) return
163 const current = accountGeneration
155164 if (photoDrafts().length + photoUrls(ownReview() ?? {}).length >= 10) { formError.set('A review can have up to 10 photos.'); return }
156165 try {
157166 if (!craft.files) throw new Error('Photo picking is unavailable in this app build.')
158167 const file = await craft.files.pickImage({source:'library',maxPixels:2400,quality:0.88})
168 if (!accountIsCurrent(current)) return
159169 if (file) photoDrafts.set([...photoDrafts(),{file}])
160170 }
161 catch (cause) { formError.set(cause instanceof Error ? cause.message : 'A photo could not be selected.') }
171 catch (cause) { if (accountIsCurrent(current)) formError.set(cause instanceof Error ? cause.message : 'A photo could not be selected.') }
162172}
163173async function submitReview() {
164 if (busy()) return
174 if (loading() || !viewerReady() || busy() || !signedIn()) return
165175 const problem = reviewFormError(rating(), content())
166176 if (problem) { formError.set(problem); return }
177 const current = accountGeneration
167178 busy.set(true)
168179 formError.set('')
169180 try {
181 const operation = await api.bindSession()
182 if (!accountIsCurrent(current)) return
170183 for (let index=0;index<photoDrafts().length;index++) {
171184 const draft=photoDrafts()[index]
172185 if (draft.id) continue
173186 const form=new FormData()
174187 form.append('photo',draft.file)
175 const response=await api.upload(`/api/trails/${wanted}/photos`,form)
188 const response=await operation.upload(`/api/trails/${wanted}/photos`,form)
189 if (!accountIsCurrent(current)) return
176190 const body=response.body
177191 if (!response.ok || !body.photo?.id) throw new Error(body.error || 'A photo could not be uploaded. Try saving again.')
178192 photoDrafts.set(photoDrafts().map((entry,at)=>at===index ? {...entry,id:body.photo.id}:entry))
179193 }
180 const response = await api.send(`/api/trails/${wanted}/reviews`, 'POST', {
194 const response = await operation.send(`/api/trails/${wanted}/reviews`, 'POST', {
181195 rating: rating(), content: content().trim(), conditions: conditions() || null,
182196 difficulty: difficulty() || null, visit_date: visitDate() || null,
183197 ...(photoDrafts().length ? {photo_ids:photoDrafts().map(draft=>draft.id),retain_photos:true}:{}),
184198 })
199 if (!accountIsCurrent(current)) return
185200 if (!response.ok) throw new Error(Object.values(response.body?.fields ?? {}).join('. ') || response.body?.error || 'The review could not be saved.')
186201 if (response.body.trail) trail.set({...trail(),...response.body.trail})
187202 composer.set(false)
188203 notice.set('Your review has been saved.')
189 try { await reloadReviews() } catch (cause) { notice.set(cause instanceof Error ? cause.message : 'Pull down to refresh reviews.') }
204 try { await reloadReviews(operation) } catch (cause) { if (accountIsCurrent(current)) notice.set(cause instanceof Error ? cause.message : 'Pull down to refresh reviews.') }
190205 }
191 catch (cause) { formError.set(cause instanceof Error ? cause.message : 'The review could not be saved.') }
192 finally { busy.set(false) }
206 catch (cause) { if (accountIsCurrent(current)) formError.set(cause instanceof Error ? cause.message : 'The review could not be saved.') }
207 finally { if (accountIsCurrent(current)) busy.set(false) }
193208}
194209async function deleteReview() {
195210 if (loading() || !viewerReady() || !ownReview() || busy()) return
196 const confirmed = await craft.dialog?.confirm({ title: 'Delete your review?', message: 'Your rating, review, and condition report will be removed from this trail.', confirmLabel: 'Delete review', destructive: true })
197 if (!confirmed) return
211 const current = accountGeneration
198212 busy.set(true)
199213 try {
200 const response = await api.send(`/api/trails/${wanted}/reviews`, 'DELETE')
214 const operation = await api.bindSession()
215 if (!accountIsCurrent(current)) return
216 const confirmed = await craft.dialog?.confirm({ title: 'Delete your review?', message: 'Your rating, review, and condition report will be removed from this trail.', confirmLabel: 'Delete review', destructive: true })
217 if (!confirmed || !accountIsCurrent(current)) return
218 const response = await operation.send(`/api/trails/${wanted}/reviews`, 'DELETE')
219 if (!accountIsCurrent(current)) return
201220 if (!response.ok) throw new Error(response.body?.error || 'The review could not be deleted.')
202221 if (response.body.trail) trail.set({...trail(),...response.body.trail})
203222 ownReview.set(null)
204223 notice.set('Your review has been deleted.')
205 try { await reloadReviews() } catch (cause) { notice.set(cause instanceof Error ? cause.message : 'Pull down to refresh reviews.') }
224 try { await reloadReviews(operation) } catch (cause) { if (accountIsCurrent(current)) notice.set(cause instanceof Error ? cause.message : 'Pull down to refresh reviews.') }
206225 }
207 catch (cause) { notice.set(cause instanceof Error ? cause.message : 'The review could not be deleted.') }
208 finally { busy.set(false) }
226 catch (cause) { if (accountIsCurrent(current)) notice.set(cause instanceof Error ? cause.message : 'The review could not be deleted.') }
227 finally { if (accountIsCurrent(current)) busy.set(false) }
209228}
210229async function load() {
230 if (busy()) return
211231 const current = ++generation
212232 loading.set(true)
213233 viewerReady.set(false)
@@ -256,7 +276,14 @@ onMount(() => {
256276 const stop = craft.navigation.onAppear(() => { void load() })
257277 return () => { alive = false; generation++; stop() }
258278})
259onMount(() => onNativeAccountChange(craft, () => { viewer.set(0); saved.set(false); ownReview.set(null); viewerReady.set(false); reviews.set([]); composer.set(false); void load() }))
279onMount(() => onNativeAccountChange(craft, () => {
280 accountGeneration++
281 busy.set(false)
282 viewer.set(0); saved.set(false); ownReview.set(null); viewerReady.set(false); reviews.set([])
283 composer.set(false); rating.set(0); content.set(''); conditions.set(''); difficulty.set(''); visitDate.set('')
284 photoDrafts.set([]); formError.set(''); notice.set('')
285 void load()
286}))
260287</script>
261288
262289<View :class="ui.screen">