also looking at this
feat: native trail maps and complete native mobile screens
#1017
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.
| @@ -146,7 +146,7 @@ function togglePause() { void perform(async () => { | ||
| 146 | 146 | async function upload() { |
| 147 | 147 | const value=recording() |
| 148 | 148 | if (!value?.pending) return |
| 149 | const api=createNativeApi(craft) | |
| 149 | const api=await createNativeApi(craft).bindSession() | |
| 150 | 150 | const me=await api.send('/api/me') |
| 151 | 151 | 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.') |
| 152 | 152 | const response=await api.send('/api/activities','POST',value.pending) |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | <script client lang="ts"> |
| 2 | 2 | import type { NativeCraft } from '@stacksjs/stx/native' |
| 3 | import type { NativeApi } from '../functions/native-api' | |
| 3 | 4 | import { onNativeAccountChange, createNativeApi, loadOutcome, nativeApiOrigin } from '../functions/native-api' |
| 4 | 5 | import { nativeBasemap, nativeBasemapOptions } from '../functions/native-basemaps' |
| 5 | 6 | import { chooseNativeChoice, nativeChoiceLabel } from '../functions/native-choice' |
| @@ -58,6 +59,7 @@ const formError = state('') | ||
| 58 | 59 | const photoDrafts = state<Array<{file:any,id?:string}>>([]) |
| 59 | 60 | let alive = true |
| 60 | 61 | let generation = 0 |
| 62 | let accountGeneration = 0 | |
| 61 | 63 | |
| 62 | 64 | const surfaces = derived(() => trailSurfaceTypes(rawTrail()?.surface)) |
| 63 | 65 | const routeAvailable = derived(() => parts().some(part => part.length >= 4)) |
| @@ -66,6 +68,7 @@ const mapRegion = derived(() => region() ?? nativeRouteRegion(parts(), {lat:trai | ||
| 66 | 68 | const photos = derived(() => trailGallery(trail(),reviews())) |
| 67 | 69 | |
| 68 | 70 | function textOf(event: any): string { return String(event?.text ?? '') } |
| 71 | function accountIsCurrent(version:number): boolean { return alive && version === accountGeneration } | |
| 69 | 72 | function profilePath(): string { return trailProfilePath(profile()) } |
| 70 | 73 | function photoUrls(row: any): string[] { return parsePhotoList(row.photos).map(url=>url.startsWith('/') ? nativeApiOrigin(craft)+url : url) } |
| 71 | 74 | function photoUrl(url:string):string {return url.startsWith('/') ? nativeApiOrigin(craft)+url : url} |
| @@ -119,15 +122,20 @@ function signedIn(): boolean { | ||
| 119 | 122 | } |
| 120 | 123 | async function toggleSaved() { |
| 121 | 124 | if (loading() || !viewerReady() || busy() || !signedIn()) return |
| 125 | const current = accountGeneration | |
| 126 | const method = saved() ? 'DELETE' : 'PUT' | |
| 122 | 127 | busy.set(true) |
| 123 | 128 | 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 | |
| 125 | 133 | if (!response.ok) throw new Error(response.body?.error || 'The trail could not be saved.') |
| 126 | 134 | saved.set(Boolean(response.body.saved)) |
| 127 | 135 | notice.set(saved() ? 'Trail saved.' : 'Trail removed from saved trails.') |
| 128 | 136 | } |
| 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) } | |
| 131 | 139 | } |
| 132 | 140 | function editReview() { |
| 133 | 141 | if (loading() || !viewerReady() || busy() || !signedIn()) return |
| @@ -141,73 +149,85 @@ function editReview() { | ||
| 141 | 149 | photoDrafts.set([]) |
| 142 | 150 | composer.set(true) |
| 143 | 151 | } |
| 144 | async function reloadReviews() { | |
| 152 | async function reloadReviews(operation: NativeApi) { | |
| 145 | 153 | const current = generation |
| 146 | 154 | 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`)]) | |
| 148 | 156 | if (!alive || current !== generation) return |
| 149 | 157 | if (personal.ok) { ownReview.set(personal.body.review ?? null); viewerReady.set(true) } |
| 150 | 158 | if (response.ok) reviews.set(response.body.reviews ?? []) |
| 151 | 159 | if (!response.ok || !personal.ok) throw new Error('Your change was saved, but reviews could not refresh. Pull down to retry.') |
| 152 | 160 | } |
| 153 | 161 | async function pickReviewPhoto() { |
| 154 | if (busy()) return | |
| 162 | if (loading() || !viewerReady() || busy() || !signedIn()) return | |
| 163 | const current = accountGeneration | |
| 155 | 164 | if (photoDrafts().length + photoUrls(ownReview() ?? {}).length >= 10) { formError.set('A review can have up to 10 photos.'); return } |
| 156 | 165 | try { |
| 157 | 166 | if (!craft.files) throw new Error('Photo picking is unavailable in this app build.') |
| 158 | 167 | const file = await craft.files.pickImage({source:'library',maxPixels:2400,quality:0.88}) |
| 168 | if (!accountIsCurrent(current)) return | |
| 159 | 169 | if (file) photoDrafts.set([...photoDrafts(),{file}]) |
| 160 | 170 | } |
| 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.') } | |
| 162 | 172 | } |
| 163 | 173 | async function submitReview() { |
| 164 | if (busy()) return | |
| 174 | if (loading() || !viewerReady() || busy() || !signedIn()) return | |
| 165 | 175 | const problem = reviewFormError(rating(), content()) |
| 166 | 176 | if (problem) { formError.set(problem); return } |
| 177 | const current = accountGeneration | |
| 167 | 178 | busy.set(true) |
| 168 | 179 | formError.set('') |
| 169 | 180 | try { |
| 181 | const operation = await api.bindSession() | |
| 182 | if (!accountIsCurrent(current)) return | |
| 170 | 183 | for (let index=0;index<photoDrafts().length;index++) { |
| 171 | 184 | const draft=photoDrafts()[index] |
| 172 | 185 | if (draft.id) continue |
| 173 | 186 | const form=new FormData() |
| 174 | 187 | 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 | |
| 176 | 190 | const body=response.body |
| 177 | 191 | if (!response.ok || !body.photo?.id) throw new Error(body.error || 'A photo could not be uploaded. Try saving again.') |
| 178 | 192 | photoDrafts.set(photoDrafts().map((entry,at)=>at===index ? {...entry,id:body.photo.id}:entry)) |
| 179 | 193 | } |
| 180 | const response = await api.send(`/api/trails/${wanted}/reviews`, 'POST', { | |
| 194 | const response = await operation.send(`/api/trails/${wanted}/reviews`, 'POST', { | |
| 181 | 195 | rating: rating(), content: content().trim(), conditions: conditions() || null, |
| 182 | 196 | difficulty: difficulty() || null, visit_date: visitDate() || null, |
| 183 | 197 | ...(photoDrafts().length ? {photo_ids:photoDrafts().map(draft=>draft.id),retain_photos:true}:{}), |
| 184 | 198 | }) |
| 199 | if (!accountIsCurrent(current)) return | |
| 185 | 200 | if (!response.ok) throw new Error(Object.values(response.body?.fields ?? {}).join('. ') || response.body?.error || 'The review could not be saved.') |
| 186 | 201 | if (response.body.trail) trail.set({...trail(),...response.body.trail}) |
| 187 | 202 | composer.set(false) |
| 188 | 203 | 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.') } | |
| 190 | 205 | } |
| 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) } | |
| 193 | 208 | } |
| 194 | 209 | async function deleteReview() { |
| 195 | 210 | 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 | |
| 198 | 212 | busy.set(true) |
| 199 | 213 | 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 | |
| 201 | 220 | if (!response.ok) throw new Error(response.body?.error || 'The review could not be deleted.') |
| 202 | 221 | if (response.body.trail) trail.set({...trail(),...response.body.trail}) |
| 203 | 222 | ownReview.set(null) |
| 204 | 223 | 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.') } | |
| 206 | 225 | } |
| 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) } | |
| 209 | 228 | } |
| 210 | 229 | async function load() { |
| 230 | if (busy()) return | |
| 211 | 231 | const current = ++generation |
| 212 | 232 | loading.set(true) |
| 213 | 233 | viewerReady.set(false) |
| @@ -256,7 +276,14 @@ onMount(() => { | ||
| 256 | 276 | const stop = craft.navigation.onAppear(() => { void load() }) |
| 257 | 277 | return () => { alive = false; generation++; stop() } |
| 258 | 278 | }) |
| 259 | onMount(() => onNativeAccountChange(craft, () => { viewer.set(0); saved.set(false); ownReview.set(null); viewerReady.set(false); reviews.set([]); composer.set(false); void load() })) | |
| 279 | onMount(() => 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 | })) | |
| 260 | 287 | </script> |
| 261 | 288 | |
| 262 | 289 | <View :class="ui.screen"> |